imapflow 1.4.6 → 1.4.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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.4.6"
2
+ ".": "1.4.8"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.8](https://github.com/postalsys/imapflow/compare/v1.4.7...v1.4.8) (2026-07-21)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **auth:** describe the real connection in the OAUTHBEARER payload ([aca7cb3](https://github.com/postalsys/imapflow/commit/aca7cb3ab7e67704cb692ad59f6db0792040a888))
9
+ * default a non-secure connection to IMAP port 143, not POP3 110 ([5896488](https://github.com/postalsys/imapflow/commit/589648821c5dd1b1750959fa2917b3571ba93db5))
10
+
11
+ ## [1.4.7](https://github.com/postalsys/imapflow/compare/v1.4.6...v1.4.7) (2026-07-10)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * treat \NonExistent mailboxes as \Noselect in LIST responses ([af21245](https://github.com/postalsys/imapflow/commit/af21245dab4e1320afc4c4458afe33d1508180c1))
17
+
3
18
  ## [1.4.6](https://github.com/postalsys/imapflow/compare/v1.4.5...v1.4.6) (2026-07-05)
4
19
 
5
20
 
@@ -40,7 +40,14 @@ async function authOauth(connection, username, accessToken) {
40
40
  // OAUTHBEARER payload per RFC 7628: fields separated by \x01 (SASL GS2 framing).
41
41
  // Format: "n,a=<user>," \x01 "host=..." \x01 "port=..." \x01 "auth=Bearer <token>" \x01 \x01
42
42
  // The trailing empty strings produce the required double-\x01 terminator.
43
- oauthbearer = [`n,a=${username},`, `host=${connection.servername}`, `port=993`, `auth=Bearer ${accessToken}`, '', ''].join('\x01');
43
+ // Both fields must describe the connection actually in use. The port was hardcoded to 993,
44
+ // so an OAuth2 server reached over 143/STARTTLS advertised a payload that did not match, and
45
+ // `servername` is set to false for a bare-IP host, which rendered as a literal "host=false".
46
+ // A server validating either field rejects with status `invalid_request` - a permanent
47
+ // failure that refreshing the access token can never clear.
48
+ oauthbearer = [`n,a=${username},`, `host=${connection.servername || connection.host}`, `port=${connection.port}`, `auth=Bearer ${accessToken}`, '', ''].join(
49
+ '\x01'
50
+ );
44
51
  command = 'OAUTHBEARER';
45
52
  // "AQ==" is base64 for \x01 -- sent as the error continuation to abort the SASL exchange
46
53
  breaker = 'AQ==';
@@ -87,6 +87,15 @@ module.exports = async (connection, reference, mailbox, options) => {
87
87
  specialUseMatches[type].push({ entry, source });
88
88
  };
89
89
 
90
+ // RFC 5258: the \NonExistent attribute implies \Noselect. Some servers only
91
+ // return \NonExistent for phantom folders, so add \Noselect as well to keep
92
+ // the flags consistent for consumers that only check \Noselect.
93
+ let normalizeFlags = entry => {
94
+ if (entry.flags.has('\\NonExistent')) {
95
+ entry.flags.add('\\Noselect');
96
+ }
97
+ };
98
+
90
99
  // User-provided hints map mailbox paths to special-use types (e.g., {sent: "Sent Items"}).
91
100
  // These override server-reported flags and name-based guesses. Converted to a
92
101
  // path-keyed lookup: { "Sent Items" => "\\Sent" }
@@ -132,6 +141,8 @@ module.exports = async (connection, reference, mailbox, options) => {
132
141
  listed: true
133
142
  };
134
143
 
144
+ normalizeFlags(entry);
145
+
135
146
  // Check user-provided hints first (highest priority)
136
147
  if (specialUseHints[entry.path]) {
137
148
  addSpecialUseMatch(entry, specialUseHints[entry.path], 'user');
@@ -300,6 +311,7 @@ module.exports = async (connection, reference, mailbox, options) => {
300
311
  existing.subscribed = true;
301
312
  // Merge any additional flags from LSUB into the LIST entry
302
313
  entry.flags.forEach(flag => existing.flags.add(flag));
314
+ normalizeFlags(existing);
303
315
  }
304
316
  // Non-listed subscribed folders are intentionally ignored
305
317
  }
package/lib/imap-flow.js CHANGED
@@ -277,12 +277,14 @@ class ImapFlow extends EventEmitter {
277
277
  */
278
278
  this.secureConnection = !!this.options.secure;
279
279
 
280
- this.port = Number(this.options.port) || (this.secureConnection ? 993 : 110);
280
+ // 993 is IMAPS, 143 is IMAP over cleartext/STARTTLS. The non-secure default used to be 110,
281
+ // which is POP3 - a client created without an explicit port could never connect.
282
+ this.port = Number(this.options.port) || (this.secureConnection ? 993 : 143);
281
283
  this.host = this.options.host || 'localhost';
282
284
  this.servername = this.options.servername ? this.options.servername : !net.isIP(this.host) ? this.host : false;
283
285
 
284
286
  if (typeof this.options.secure === 'undefined' && this.port === 993) {
285
- // if secure option is not set but port is 465, then default to secure
287
+ // if secure option is not set but port is 993, then default to secure
286
288
  this.secureConnection = true;
287
289
  }
288
290
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.4.6",
3
+ "version": "1.4.8",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -28,18 +28,18 @@
28
28
  "homepage": "https://imapflow.com/",
29
29
  "devDependencies": {
30
30
  "@eslint/js": "10.0.1",
31
- "@types/node": "26.1.0",
32
- "c8": "11.0.0",
33
- "eslint": "10.6.0",
31
+ "@types/node": "26.1.1",
32
+ "c8": "12.0.0",
33
+ "eslint": "10.7.0",
34
34
  "eslint-config-nodemailer": "1.2.0",
35
35
  "eslint-config-prettier": "10.1.8",
36
36
  "grunt": "1.6.2",
37
37
  "grunt-cli": "1.5.0",
38
38
  "grunt-contrib-nodeunit": "5.0.0",
39
39
  "grunt-eslint": "26.0.0",
40
- "prettier": "3.9.4",
40
+ "prettier": "3.9.5",
41
41
  "proxyquire": "^2.1.3",
42
- "typescript": "6.0.3"
42
+ "typescript": "7.0.2"
43
43
  },
44
44
  "dependencies": {
45
45
  "@zone-eu/mailsplit": "5.4.14",
@@ -30,6 +30,9 @@ const createMockConnection = (overrides = {}) => {
30
30
  states,
31
31
  state: overrides.state || states.SELECTED,
32
32
  id: 'test-connection-id',
33
+ // Mirrors imap-flow.js, which always resolves a port before authenticating. Without a
34
+ // default the OAUTHBEARER payload builds `port=undefined`.
35
+ port: overrides.port || 993,
33
36
  capabilities: new Map(overrides.capabilities || [['IMAP4rev1', true]]),
34
37
  enabled: new Set(overrides.enabled || []),
35
38
  authCapabilities: new Map(),
@@ -58,6 +61,9 @@ const createMockConnection = (overrides = {}) => {
58
61
  };
59
62
  };
60
63
 
64
+ // Decodes the base64 SASL payload that authenticate() hands to exec().
65
+ const decodeSaslPayload = execArgs => Buffer.from(execArgs.args[1].value, 'base64').toString();
66
+
61
67
  // ============================================
62
68
  // CAPABILITY Command Tests
63
69
  // ============================================
@@ -3550,6 +3556,94 @@ module.exports['Commands: list skips Noselect folders for status'] = async test
3550
3556
  test.done();
3551
3557
  };
3552
3558
 
3559
+ module.exports['Commands: list adds Noselect to NonExistent mailboxes'] = async test => {
3560
+ const connection = createMockConnection({
3561
+ state: 3,
3562
+ exec: async (cmd, attrs, opts) => {
3563
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3564
+ await opts.untagged.LIST({
3565
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3566
+ });
3567
+ await opts.untagged.LIST({
3568
+ attributes: [[{ value: '\\NonExistent' }], { value: '/' }, { value: 'Phantom' }]
3569
+ });
3570
+ }
3571
+ return { next: () => {} };
3572
+ }
3573
+ });
3574
+
3575
+ const result = await listCommand(connection, '', '*');
3576
+ const phantom = result.find(e => e.path === 'Phantom');
3577
+ test.ok(phantom);
3578
+ // RFC 5258: \\NonExistent implies \\Noselect
3579
+ test.equal(phantom.flags.has('\\Noselect'), true);
3580
+ // The original flag is preserved, not replaced
3581
+ test.equal(phantom.flags.has('\\NonExistent'), true);
3582
+ const inbox = result.find(e => e.path === 'INBOX');
3583
+ test.ok(inbox);
3584
+ test.equal(inbox.flags.has('\\Noselect'), false);
3585
+ test.done();
3586
+ };
3587
+
3588
+ module.exports['Commands: list LSUB merge adds Noselect to NonExistent'] = async test => {
3589
+ const connection = createMockConnection({
3590
+ state: 3,
3591
+ exec: async (cmd, attrs, opts) => {
3592
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3593
+ await opts.untagged.LIST({
3594
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
3595
+ });
3596
+ }
3597
+ if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
3598
+ // Some servers only report \\NonExistent in LSUB responses
3599
+ await opts.untagged.LSUB({
3600
+ attributes: [[{ value: '\\NonExistent' }], { value: '/' }, { value: 'Folder1' }]
3601
+ });
3602
+ }
3603
+ return { next: () => {} };
3604
+ }
3605
+ });
3606
+
3607
+ const result = await listCommand(connection, '', '*');
3608
+ const folder = result.find(e => e.path === 'Folder1');
3609
+ test.ok(folder);
3610
+ test.equal(folder.subscribed, true);
3611
+ test.equal(folder.flags.has('\\NonExistent'), true);
3612
+ // RFC 5258: \\NonExistent merged from LSUB implies \\Noselect
3613
+ test.equal(folder.flags.has('\\Noselect'), true);
3614
+ test.done();
3615
+ };
3616
+
3617
+ module.exports['Commands: list does not STATUS NonExistent mailboxes'] = async test => {
3618
+ let statusPaths = [];
3619
+ const connection = createMockConnection({
3620
+ state: 3,
3621
+ capabilities: new Map(),
3622
+ run: async (cmd, path) => {
3623
+ if (cmd === 'STATUS') {
3624
+ statusPaths.push(path);
3625
+ return { messages: 10 };
3626
+ }
3627
+ },
3628
+ exec: async (cmd, attrs, opts) => {
3629
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3630
+ await opts.untagged.LIST({
3631
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3632
+ });
3633
+ await opts.untagged.LIST({
3634
+ attributes: [[{ value: '\\NonExistent' }], { value: '/' }, { value: 'Phantom' }]
3635
+ });
3636
+ }
3637
+ return { next: () => {} };
3638
+ }
3639
+ });
3640
+
3641
+ await listCommand(connection, '', '*', { statusQuery: { messages: true } });
3642
+ // STATUS runs for the selectable mailbox only
3643
+ test.deepEqual(statusPaths, ['INBOX']);
3644
+ test.done();
3645
+ };
3646
+
3553
3647
  module.exports['Commands: list XLIST removes Inbox flag from non-INBOX'] = async test => {
3554
3648
  const connection = createMockConnection({
3555
3649
  state: 3,
@@ -7353,6 +7447,55 @@ module.exports['Commands: authenticate with OAUTHBEARER'] = async test => {
7353
7447
  test.equal(execArgs.cmd, 'AUTHENTICATE');
7354
7448
  test.equal(execArgs.args[0].value, 'OAUTHBEARER');
7355
7449
  test.ok(connection.authCapabilities.has('AUTH=OAUTHBEARER'));
7450
+ test.ok(decodeSaslPayload(execArgs).includes('port=993'), 'the payload should report the connection port');
7451
+ test.done();
7452
+ };
7453
+
7454
+ module.exports['Commands: OAUTHBEARER payload reports the port actually in use'] = async test => {
7455
+ // Regression: the port field was hardcoded to 993 - see lib/commands/authenticate.js.
7456
+ let execArgs = null;
7457
+ const connection = createMockConnection({
7458
+ state: 1,
7459
+ capabilities: new Map([['AUTH=OAUTHBEARER', true]]),
7460
+ servername: 'imap.example.com',
7461
+ port: 143,
7462
+ authCapabilities: new Map(),
7463
+ exec: async (cmd, args) => {
7464
+ execArgs = { cmd, args };
7465
+ return { next: () => {} };
7466
+ },
7467
+ write: () => {}
7468
+ });
7469
+
7470
+ await authenticateCommand(connection, 'user@example.com', { accessToken: 'token123' });
7471
+
7472
+ const payload = decodeSaslPayload(execArgs);
7473
+ test.ok(payload.includes('port=143'), `expected port=143 in the SASL payload, got: ${JSON.stringify(payload)}`);
7474
+ test.ok(payload.includes('host=imap.example.com'), 'the host field should still describe the connection');
7475
+ test.done();
7476
+ };
7477
+
7478
+ module.exports['Commands: OAUTHBEARER payload falls back to host when servername is false'] = async test => {
7479
+ // imap-flow.js sets servername = false for a bare-IP host, which rendered as "host=false".
7480
+ let execArgs = null;
7481
+ const connection = createMockConnection({
7482
+ state: 1,
7483
+ capabilities: new Map([['AUTH=OAUTHBEARER', true]]),
7484
+ servername: false,
7485
+ host: '198.51.100.7',
7486
+ authCapabilities: new Map(),
7487
+ exec: async (cmd, args) => {
7488
+ execArgs = { cmd, args };
7489
+ return { next: () => {} };
7490
+ },
7491
+ write: () => {}
7492
+ });
7493
+
7494
+ await authenticateCommand(connection, 'user@example.com', { accessToken: 'token123' });
7495
+
7496
+ const payload = decodeSaslPayload(execArgs);
7497
+ test.ok(payload.includes('host=198.51.100.7'), `expected the host as fallback, got: ${JSON.stringify(payload)}`);
7498
+ test.ok(!payload.includes('host=false'), 'the literal "host=false" must never be sent');
7356
7499
  test.done();
7357
7500
  };
7358
7501
 
@@ -143,7 +143,7 @@ module.exports['Connection Edge: Connection with default values'] = test => {
143
143
  });
144
144
 
145
145
  test.ok(client, 'Client should be created with defaults');
146
- test.equal(client.port, 110, 'Should use default port');
146
+ test.equal(client.port, 143, 'Should use default port');
147
147
  test.equal(client.secureConnection, false, 'Should default to non-secure');
148
148
  test.done();
149
149
  };
@@ -155,7 +155,7 @@ module.exports['Connection Edge: Port number handling'] = test => {
155
155
  secure: false,
156
156
  auth: { user: 'test', pass: 'test' }
157
157
  });
158
- test.equal(client1.port, 110, 'Default non-secure port');
158
+ test.equal(client1.port, 143, 'Default non-secure port');
159
159
 
160
160
  let client2 = new ImapFlow({
161
161
  host: 'imap.example.com',
@@ -20,7 +20,7 @@ module.exports['Connection: Default options'] = test => {
20
20
  auth: { user: 'test', pass: 'test' }
21
21
  });
22
22
 
23
- test.equal(client.port, 110);
23
+ test.equal(client.port, 143);
24
24
  test.equal(client.secureConnection, false);
25
25
  test.done();
26
26
  };
@@ -1,12 +0,0 @@
1
- name: "ImapFlow CodeQL config"
2
-
3
- # Exclude code that is not part of the published library runtime from analysis.
4
- # These paths generate only false-positive noise:
5
- # - test fixtures intentionally feed malformed/hostile input and exercise
6
- # edge cases that are not representative of production usage
7
- # - the example scripts hardcode demo credentials and connection details for
8
- # local experimentation and are not maintained as production code
9
- paths-ignore:
10
- - test
11
- - '**/test/**'
12
- - examples
@@ -1,102 +0,0 @@
1
- # For most projects, this workflow file will not need changing; you simply need
2
- # to commit it to your repository.
3
- #
4
- # You may wish to alter this file to override the set of languages analyzed,
5
- # or to provide custom queries or build logic.
6
- #
7
- # ******** NOTE ********
8
- # We have attempted to detect the languages in your repository. Please check
9
- # the `language` matrix defined below to confirm you have the correct set of
10
- # supported CodeQL languages.
11
- #
12
- name: "CodeQL Advanced"
13
-
14
- on:
15
- push:
16
- branches: [ "master" ]
17
- pull_request:
18
- branches: [ "master" ]
19
- schedule:
20
- - cron: '40 17 * * 6'
21
-
22
- jobs:
23
- analyze:
24
- name: Analyze (${{ matrix.language }})
25
- # Runner size impacts CodeQL analysis time. To learn more, please see:
26
- # - https://gh.io/recommended-hardware-resources-for-running-codeql
27
- # - https://gh.io/supported-runners-and-hardware-resources
28
- # - https://gh.io/using-larger-runners (GitHub.com only)
29
- # Consider using larger runners or machines with greater resources for possible analysis time improvements.
30
- runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
31
- permissions:
32
- # required for all workflows
33
- security-events: write
34
-
35
- # required to fetch internal or private CodeQL packs
36
- packages: read
37
-
38
- # only required for workflows in private repositories
39
- actions: read
40
- contents: read
41
-
42
- strategy:
43
- fail-fast: false
44
- matrix:
45
- include:
46
- - language: actions
47
- build-mode: none
48
- - language: javascript-typescript
49
- build-mode: none
50
- # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
51
- # Use `c-cpp` to analyze code written in C, C++ or both
52
- # Use 'java-kotlin' to analyze code written in Java, Kotlin or both
53
- # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
54
- # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
55
- # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
56
- # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
57
- # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
58
- steps:
59
- - name: Checkout repository
60
- uses: actions/checkout@v6
61
-
62
- # Add any setup steps before running the `github/codeql-action/init` action.
63
- # This includes steps like installing compilers or runtimes (`actions/setup-node`
64
- # or others). This is typically only required for manual builds.
65
- # - name: Setup runtime (example)
66
- # uses: actions/setup-example@v1
67
-
68
- # Initializes the CodeQL tools for scanning.
69
- - name: Initialize CodeQL
70
- uses: github/codeql-action/init@v4
71
- with:
72
- languages: ${{ matrix.language }}
73
- build-mode: ${{ matrix.build-mode }}
74
- config-file: ./.github/codeql/codeql-config.yml
75
- # If you wish to specify custom queries, you can do so here or in a config file.
76
- # By default, queries listed here will override any specified in a config file.
77
- # Prefix the list here with "+" to use these queries and those in the config file.
78
-
79
- # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
80
- # queries: security-extended,security-and-quality
81
-
82
- # If the analyze step fails for one of the languages you are analyzing with
83
- # "We were unable to automatically build your code", modify the matrix above
84
- # to set the build mode to "manual" for that language. Then modify this step
85
- # to build your code.
86
- # ℹ️ Command-line programs to run using the OS shell.
87
- # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
88
- - name: Run manual build steps
89
- if: matrix.build-mode == 'manual'
90
- shell: bash
91
- run: |
92
- echo 'If you are using a "manual" build mode for one or more of the' \
93
- 'languages you are analyzing, replace this with the commands to build' \
94
- 'your code, for example:'
95
- echo ' make bootstrap'
96
- echo ' make release'
97
- exit 1
98
-
99
- - name: Perform CodeQL Analysis
100
- uses: github/codeql-action/analyze@v4
101
- with:
102
- category: "/language:${{matrix.language}}"