imapflow 1.4.8 → 1.5.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/.github/workflows/test.yml +20 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/CLAUDE.md +12 -5
- package/Gruntfile.js +3 -1
- package/lib/commands/authenticate.js +8 -3
- package/lib/commands/enable.js +13 -4
- package/lib/commands/expunge.js +2 -2
- package/lib/commands/fetch.js +18 -14
- package/lib/commands/idle.js +6 -3
- package/lib/commands/list.js +241 -61
- package/lib/commands/move.js +2 -2
- package/lib/commands/namespace.js +3 -1
- package/lib/commands/search.js +88 -13
- package/lib/commands/status.js +19 -26
- package/lib/handler/imap-compiler.js +12 -9
- package/lib/handler/token-parser.js +7 -0
- package/lib/imap-flow.d.ts +19 -3
- package/lib/imap-flow.js +58 -9
- package/lib/search-compiler.js +15 -1
- package/lib/tools.js +173 -9
- package/package.json +3 -2
- package/test/commands-branches-test.js +11 -4
- package/test/commands-integration-test.js +1528 -108
- package/test/connection-edge-cases-test.js +4 -40
- package/test/fixtures/test-tls.js +2 -2
- package/test/handler-branches-test.js +4 -3
- package/test/imap-compiler-test.js +85 -0
- package/test/imap-flow-coverage-test.js +8 -1
- package/test/imap-flow-fetch-download-test.js +57 -4
- package/test/imap-flow-internals-test.js +2 -2
- package/test/imap-flow-methods-test.js +65 -6
- package/test/imap-flow-secure-test.js +25 -11
- package/test/imap-flow-server-test.js +80 -0
- package/test/imap-parser-test.js +113 -3
- package/test/imap-stream-test.js +46 -0
- package/test/integration/README.md +52 -0
- package/test/integration/dovecot-test.conf +27 -0
- package/test/integration/rev2-live-test.js +367 -0
- package/test/integration/run-rev2-tests.sh +75 -0
- package/test/reliability-improvements-test.js +4 -1
- package/test/search-compiler-test.js +36 -0
- package/test/search-test.js +52 -54
- package/test/tools-test.js +176 -19
|
@@ -29,3 +29,23 @@ jobs:
|
|
|
29
29
|
cache: npm
|
|
30
30
|
- run: npm install
|
|
31
31
|
- run: npm test
|
|
32
|
+
|
|
33
|
+
test-rev2:
|
|
34
|
+
# Live IMAP4rev2 integration tests against a real Dovecot 2.4 server in
|
|
35
|
+
# Docker on linux/amd64 (ubuntu runners are amd64 with Docker preinstalled).
|
|
36
|
+
# This is the only place the suite runs on amd64 - Apple Silicon dev
|
|
37
|
+
# machines cannot run the amd64 image under Rosetta.
|
|
38
|
+
name: Live IMAP4rev2 tests (Dovecot, linux/amd64)
|
|
39
|
+
timeout-minutes: 15
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@v6
|
|
43
|
+
- name: Use Node.js 24.x
|
|
44
|
+
uses: actions/setup-node@v6
|
|
45
|
+
with:
|
|
46
|
+
node-version: 24.x
|
|
47
|
+
cache: npm
|
|
48
|
+
- run: npm install
|
|
49
|
+
- run: npm run test:rev2
|
|
50
|
+
env:
|
|
51
|
+
IMAPFLOW_DOVECOT_PLATFORM: linux/amd64
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.5.0](https://github.com/postalsys/imapflow/compare/v1.4.9...v1.5.0) (2026-07-23)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add STATUS SIZE/DELETED and rev2 BINARY fetch support, fix protocol bugs found in an RFC 9051 review ([04f5bf6](https://github.com/postalsys/imapflow/commit/04f5bf6c1a49e155dd23b2af062e570530b814b1))
|
|
9
|
+
|
|
10
|
+
## [1.4.9](https://github.com/postalsys/imapflow/compare/v1.4.8...v1.4.9) (2026-07-22)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **folder:** pass StatusObject value instead of its boolean primitive ([043e248](https://github.com/postalsys/imapflow/commit/043e24802f2d73fac297d051a673fe9bc9eb3b25))
|
|
16
|
+
* **list:** request subscription state inline and support IMAP4rev2 ([93e899d](https://github.com/postalsys/imapflow/commit/93e899ded64b219e7fbac2eff083890cb4baa382))
|
|
17
|
+
|
|
3
18
|
## [1.4.8](https://github.com/postalsys/imapflow/compare/v1.4.7...v1.4.8) (2026-07-21)
|
|
4
19
|
|
|
5
20
|
|
package/CLAUDE.md
CHANGED
|
@@ -38,13 +38,15 @@ npm run coverage # Run tests under c8 coverage (text + html reports)
|
|
|
38
38
|
npm run lint # Lint with ESLint
|
|
39
39
|
npm run format # Format with Prettier (js, json, md, yml, yaml)
|
|
40
40
|
npm run update # Refresh deps: remove node_modules + lockfile, ncu -u, npm install
|
|
41
|
+
npm run test:rev2 # Live IMAP4rev2 tests against Dovecot in Docker (see test/integration/)
|
|
41
42
|
```
|
|
42
43
|
|
|
43
44
|
## Testing
|
|
44
45
|
|
|
45
|
-
- Tests live in `test/` and are named `*-test.js`; the Grunt nodeunit glob only matches that pattern, so helpers/fixtures are never run as tests.
|
|
46
|
+
- Tests live in `test/` and are named `*-test.js`; the Grunt nodeunit glob only matches that pattern, so helpers/fixtures are never run as tests. Exception: `test/integration/` is excluded from the glob - those tests need Docker and run only via `npm run test:rev2`.
|
|
46
47
|
- `npm test` runs `grunt`, which runs ESLint first, then the nodeunit suite. Keep the suite green and lint-clean before committing.
|
|
47
48
|
- New tests go in `test/` as `*-test.js`. The parser, command compiler, and search compiler are the most security-sensitive areas - add hostile/malformed-input cases there.
|
|
49
|
+
- `npm run test:rev2` starts a Dovecot 2.4 container (real IMAP4rev2 server) and runs `test/integration/rev2-live-test.js` against it - use it to verify rev2-facing changes end to end, mocks alone are not enough.
|
|
48
50
|
|
|
49
51
|
## Packaging Constraints (IMPORTANT)
|
|
50
52
|
|
|
@@ -59,6 +61,13 @@ CommonJS-compatible:
|
|
|
59
61
|
- ImapFlow source stays CommonJS (`require`/`module.exports`). Do not convert the library to ESM.
|
|
60
62
|
- Do not add a dependency that is pure ESM (`"type": "module"` with only an `import`/ESM entry and no CommonJS export). It must be `require()`-able.
|
|
61
63
|
- When `npm run update` or a new dependency would pull in a pure-ESM package (a common outcome of major-version bumps), pin to the last CommonJS-compatible version instead, or find a CommonJS alternative. Verify with a quick `require()` of the package after updating.
|
|
64
|
+
- After every `npm run update`, run this check to confirm all production dependencies are still CommonJS (it must print `CJS OK` for every dependency and report no pure-ESM packages), then run `npm test`:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
node -e "Object.keys(require('./package.json').dependencies).forEach(d => { require(d); console.log('CJS OK:', d); })"
|
|
68
|
+
node -e "const fs=require('fs');const bad=Object.keys(require('./package.json').dependencies).filter(d=>JSON.parse(fs.readFileSync(require.resolve(d+'/package.json'),'utf8')).type==='module');console.log(bad.length?'PURE-ESM DEPS FOUND: '+bad.join(', '):'No pure-ESM production dependencies')"
|
|
69
|
+
```
|
|
70
|
+
|
|
62
71
|
- Keep dynamic `require()` paths static enough for `pkg` to detect; avoid building module paths at runtime in ways the bundler can't trace.
|
|
63
72
|
|
|
64
73
|
## Code Style Rules
|
|
@@ -72,7 +81,7 @@ CommonJS-compatible:
|
|
|
72
81
|
1. Run `npm run format` and `npm run lint`
|
|
73
82
|
2. Run `npm test` and keep it green
|
|
74
83
|
3. For non-trivial changes, run `/simplify` to review changed code and `/security-review` to check for security issues before committing
|
|
75
|
-
- After pushing, check the GitHub Actions runs for the push (e.g. `gh run list --branch master`) and report their status
|
|
84
|
+
- After pushing, check the GitHub Actions runs for the push (e.g. `gh run list --branch master`) and report their status. If a run fails for a strange or unrelated reason (for example a checkout step reporting "account suspended", HTTP 403, or other auth/infrastructure errors that have nothing to do with the change), check <https://www.githubstatus.com/> for an active GitHub incident before assuming the failure is caused by the change.
|
|
76
85
|
|
|
77
86
|
## Relationship to EmailEngine
|
|
78
87
|
|
|
@@ -88,9 +97,7 @@ Packaging Constraints).
|
|
|
88
97
|
## Security
|
|
89
98
|
|
|
90
99
|
Security policy and private reporting channels are documented in
|
|
91
|
-
[`SECURITY.md`](SECURITY.md) / [`SECURITY.txt`](SECURITY.txt).
|
|
92
|
-
through the "CodeQL Advanced" GitHub Actions workflow
|
|
93
|
-
(`.github/workflows/codeql.yml`, config in `.github/codeql/codeql-config.yml`).
|
|
100
|
+
[`SECURITY.md`](SECURITY.md) / [`SECURITY.txt`](SECURITY.txt).
|
|
94
101
|
|
|
95
102
|
## Release Process
|
|
96
103
|
|
package/Gruntfile.js
CHANGED
|
@@ -8,7 +8,9 @@ module.exports = function (grunt) {
|
|
|
8
8
|
},
|
|
9
9
|
|
|
10
10
|
nodeunit: {
|
|
11
|
-
|
|
11
|
+
// test/integration is excluded: those tests need a live Docker server
|
|
12
|
+
// and run via `npm run test:rev2` instead
|
|
13
|
+
all: ['test/**/*-test.js', '!test/integration/**']
|
|
12
14
|
}
|
|
13
15
|
});
|
|
14
16
|
|
|
@@ -45,9 +45,14 @@ async function authOauth(connection, username, accessToken) {
|
|
|
45
45
|
// `servername` is set to false for a bare-IP host, which rendered as a literal "host=false".
|
|
46
46
|
// A server validating either field rejects with status `invalid_request` - a permanent
|
|
47
47
|
// failure that refreshing the access token can never clear.
|
|
48
|
-
oauthbearer = [
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
oauthbearer = [
|
|
49
|
+
`n,a=${username},`,
|
|
50
|
+
`host=${connection.servername || connection.host}`,
|
|
51
|
+
`port=${connection.port}`,
|
|
52
|
+
`auth=Bearer ${accessToken}`,
|
|
53
|
+
'',
|
|
54
|
+
''
|
|
55
|
+
].join('\x01');
|
|
51
56
|
command = 'OAUTHBEARER';
|
|
52
57
|
// "AQ==" is base64 for \x01 -- sent as the error continuation to abort the SASL exchange
|
|
53
58
|
breaker = 'AQ==';
|
package/lib/commands/enable.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { hasCapability } = require('../tools.js');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Enables IMAP extensions on the server.
|
|
5
7
|
*
|
|
@@ -8,14 +10,18 @@
|
|
|
8
10
|
* @returns {Promise<Set|boolean|undefined>} Set of enabled extensions, false on failure, or undefined if not applicable
|
|
9
11
|
*/
|
|
10
12
|
module.exports = async (connection, extensionList) => {
|
|
11
|
-
|
|
13
|
+
// ENABLE is part of base IMAP4rev2, so rev2-only servers may omit the token
|
|
14
|
+
if (!hasCapability(connection, 'ENABLE') || connection.state !== connection.states.AUTHENTICATED) {
|
|
12
15
|
// nothing to do here
|
|
13
16
|
return;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
// Pre-filter: only request extensions the server actually advertised in its
|
|
17
20
|
// CAPABILITY response. Requesting unsupported extensions would cause an error.
|
|
18
|
-
|
|
21
|
+
// Compared case-insensitively - the capability map keeps canonical casing for
|
|
22
|
+
// some keys (e.g. IMAP4rev2).
|
|
23
|
+
let advertised = new Set([...connection.capabilities.keys()].map(capability => capability.toUpperCase()));
|
|
24
|
+
extensionList = extensionList.filter(extension => advertised.has(extension.toUpperCase()));
|
|
19
25
|
if (!extensionList.length) {
|
|
20
26
|
return;
|
|
21
27
|
}
|
|
@@ -44,9 +50,12 @@ module.exports = async (connection, extensionList) => {
|
|
|
44
50
|
}
|
|
45
51
|
}
|
|
46
52
|
);
|
|
47
|
-
|
|
53
|
+
// Merge instead of replace - the untagged ENABLED response only lists
|
|
54
|
+
// extensions enabled by this command (RFC 5161), so a replace would drop
|
|
55
|
+
// grants from an earlier ENABLE call
|
|
56
|
+
connection.enabled = new Set([...connection.enabled, ...enabled]);
|
|
48
57
|
response.next();
|
|
49
|
-
return enabled;
|
|
58
|
+
return connection.enabled;
|
|
50
59
|
} catch (err) {
|
|
51
60
|
connection.log.warn({ err, cid: connection.id });
|
|
52
61
|
return false;
|
package/lib/commands/expunge.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { enhanceCommandError } = require('../tools.js');
|
|
3
|
+
const { enhanceCommandError, hasCapability } = require('../tools.js');
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Deletes specified messages by flagging them as Deleted and expunging.
|
|
@@ -27,7 +27,7 @@ module.exports = async (connection, range, options) => {
|
|
|
27
27
|
// With UIDPLUS (RFC 4315): "UID EXPUNGE <uids>" removes only the specified UIDs,
|
|
28
28
|
// leaving other \Deleted messages untouched -- important for concurrent access.
|
|
29
29
|
// Without UIDPLUS: plain "EXPUNGE" removes ALL messages flagged \Deleted in the mailbox.
|
|
30
|
-
let byUid = options.uid && connection
|
|
30
|
+
let byUid = options.uid && hasCapability(connection, 'UIDPLUS');
|
|
31
31
|
let command = byUid ? 'UID EXPUNGE' : 'EXPUNGE';
|
|
32
32
|
let attributes = byUid ? [{ type: 'SEQUENCE', value: range }] : false;
|
|
33
33
|
|
package/lib/commands/fetch.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { formatMessageResponse } = require('../tools');
|
|
3
|
+
const { formatMessageResponse, isRev2Active } = require('../tools');
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Fetches emails from the server.
|
|
@@ -25,8 +25,12 @@ module.exports = async (connection, range, query, options) => {
|
|
|
25
25
|
|
|
26
26
|
let mailbox = connection.mailbox;
|
|
27
27
|
|
|
28
|
-
// Use BINARY extension for fetching if supported and requested, otherwise fall back to BODY
|
|
29
|
-
|
|
28
|
+
// Use BINARY extension for fetching if supported and requested, otherwise fall back to BODY.
|
|
29
|
+
// RFC 9051 folds the FETCH side of the BINARY extension into base IMAP4rev2, so an active
|
|
30
|
+
// rev2 session can use it even without the BINARY capability token (the APPEND side is NOT
|
|
31
|
+
// folded in and stays gated on the token in append.js)
|
|
32
|
+
const canUseBinary = connection.capabilities.has('BINARY') || isRev2Active(connection);
|
|
33
|
+
const commandKey = canUseBinary && options.binary && !connection.disableBinary ? 'BINARY' : 'BODY';
|
|
30
34
|
|
|
31
35
|
// Retry logic for ETHROTTLE errors (server rate limiting) with exponential backoff
|
|
32
36
|
let retryCount = 0;
|
|
@@ -50,21 +54,21 @@ module.exports = async (connection, range, query, options) => {
|
|
|
50
54
|
// PEEK avoids marking messages as \Seen. Section identifies what to fetch (HEADER, specific part, etc.)
|
|
51
55
|
// Partial is an optional byte range [start, maxLength].
|
|
52
56
|
let setBodyPeek = (attributes, partial) => {
|
|
57
|
+
let section = [].concat(attributes || []);
|
|
58
|
+
|
|
59
|
+
// BINARY may only address the empty section or a numeric part specifier
|
|
60
|
+
// (RFC 3516 / RFC 9051 section-binary) - HEADER, HEADER.FIELDS, TEXT and
|
|
61
|
+
// n.MIME are invalid after BINARY and must stay BODY fetches
|
|
62
|
+
let binaryAddressable =
|
|
63
|
+
!section.length || (section.length === 1 && typeof section[0].value === 'string' && /^\d+(\.\d+)*$/.test(section[0].value));
|
|
64
|
+
|
|
53
65
|
let bodyPeek = {
|
|
54
66
|
type: 'ATOM',
|
|
55
|
-
value: `${commandKey}.PEEK`,
|
|
56
|
-
section
|
|
67
|
+
value: `${binaryAddressable ? commandKey : 'BODY'}.PEEK`,
|
|
68
|
+
section,
|
|
57
69
|
partial
|
|
58
70
|
};
|
|
59
71
|
|
|
60
|
-
if (Array.isArray(attributes)) {
|
|
61
|
-
attributes.forEach(attribute => {
|
|
62
|
-
bodyPeek.section.push(attribute);
|
|
63
|
-
});
|
|
64
|
-
} else if (attributes) {
|
|
65
|
-
bodyPeek.section.push(attributes);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
72
|
queryStructure.push(bodyPeek);
|
|
69
73
|
};
|
|
70
74
|
|
|
@@ -88,7 +92,7 @@ module.exports = async (connection, range, query, options) => {
|
|
|
88
92
|
partial.push(Number(query.source.maxLength));
|
|
89
93
|
}
|
|
90
94
|
}
|
|
91
|
-
|
|
95
|
+
setBodyPeek(null, partial);
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
// Always request a unique email ID for message deduplication.
|
package/lib/commands/idle.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { hasCapability } = require('../tools.js');
|
|
4
|
+
|
|
3
5
|
const NOOP_INTERVAL = 2 * 60 * 1000;
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -140,9 +142,10 @@ module.exports = async (connection, maxIdleTime) => {
|
|
|
140
142
|
return;
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
// If server supports IDLE (RFC 2177), use it for
|
|
144
|
-
// Otherwise, fall back to periodic polling with
|
|
145
|
-
|
|
145
|
+
// If server supports IDLE (RFC 2177, folded into base IMAP4rev2), use it for
|
|
146
|
+
// real-time push notifications. Otherwise, fall back to periodic polling with
|
|
147
|
+
// NOOP/STATUS/SELECT.
|
|
148
|
+
if (hasCapability(connection, 'IDLE')) {
|
|
146
149
|
let idleTimer;
|
|
147
150
|
let stillIdling = false;
|
|
148
151
|
// IDLE loop: runs IDLE, and if maxIdleTime is reached, breaks and restarts
|