imapkit 5.1.0 → 5.3.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/README.md +41 -23
- package/bin/help.txt +29 -3
- package/bin/imapkit.js +43 -0
- package/dist/browser/imapkit.js +99 -35
- package/dist/cjs/control.d.ts +16 -5
- package/dist/cjs/control.js +36 -5
- package/dist/cjs/oauth/http.d.ts +28 -0
- package/dist/cjs/oauth/http.js +216 -0
- package/dist/cjs/oauth/jwt.d.ts +33 -0
- package/dist/cjs/oauth/jwt.js +58 -0
- package/dist/cjs/oauth/pages.d.ts +47 -0
- package/dist/cjs/oauth/pages.js +310 -0
- package/dist/cjs/oauth/policy.d.ts +48 -0
- package/dist/cjs/oauth/policy.js +66 -0
- package/dist/cjs/oauth/provider.d.ts +455 -0
- package/dist/cjs/oauth/provider.js +1422 -0
- package/dist/cjs/plugins/oauthbearer.js +10 -15
- package/dist/cjs/plugins/xoauth2.js +4 -3
- package/dist/cjs/rest.d.ts +18 -1
- package/dist/cjs/rest.js +63 -35
- package/dist/cjs/server.d.ts +49 -1
- package/dist/cjs/server.js +126 -12
- package/dist/cjs/smtp-listener.d.ts +3 -2
- package/dist/cjs/smtp-listener.js +15 -3
- package/dist/cjs/types.d.ts +16 -0
- package/dist/esm/control.d.ts +16 -5
- package/dist/esm/control.js +36 -6
- package/dist/esm/oauth/http.d.ts +28 -0
- package/dist/esm/oauth/http.js +211 -0
- package/dist/esm/oauth/jwt.d.ts +33 -0
- package/dist/esm/oauth/jwt.js +50 -0
- package/dist/esm/oauth/pages.d.ts +47 -0
- package/dist/esm/oauth/pages.js +305 -0
- package/dist/esm/oauth/policy.d.ts +48 -0
- package/dist/esm/oauth/policy.js +59 -0
- package/dist/esm/oauth/provider.d.ts +455 -0
- package/dist/esm/oauth/provider.js +1415 -0
- package/dist/esm/plugins/oauthbearer.js +10 -15
- package/dist/esm/plugins/xoauth2.js +4 -3
- package/dist/esm/rest.d.ts +18 -1
- package/dist/esm/rest.js +62 -36
- package/dist/esm/server.d.ts +49 -1
- package/dist/esm/server.js +127 -13
- package/dist/esm/smtp-listener.d.ts +3 -2
- package/dist/esm/smtp-listener.js +15 -3
- package/dist/esm/types.d.ts +16 -0
- package/dist/plugin-help.json +2 -2
- package/package.json +7 -7
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @help Works with and without SASL-IR [RFC4959]
|
|
4
4
|
* @help Valid login info, same as for XOAUTH2:
|
|
5
5
|
* @help Username (authzid, optional): testuser
|
|
6
|
-
* @help Access Token: testtoken
|
|
6
|
+
* @help Access Token: testtoken, or a token of the OAuth provider
|
|
7
7
|
*/
|
|
8
8
|
const KVSEP = '\x01';
|
|
9
9
|
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
@@ -89,21 +89,16 @@ export default function oauthbearerPlugin(server) {
|
|
|
89
89
|
return auth === '' ? 'invalid_token' : 'invalid_request';
|
|
90
90
|
}
|
|
91
91
|
const token = bearer[1];
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
const user = server.getUser(username);
|
|
101
|
-
if (!user || !user.xoauth2 || user.xoauth2.accessToken !== token) {
|
|
102
|
-
return 'invalid_token';
|
|
103
|
-
}
|
|
104
|
-
request.username = username;
|
|
92
|
+
// without an authzid the token tells who the user is
|
|
93
|
+
const result = server.checkAccessToken(token, request.authzid === false ? undefined : request.authzid);
|
|
94
|
+
if ('error' in result) {
|
|
95
|
+
return result.error;
|
|
96
|
+
}
|
|
97
|
+
request.username = result.user;
|
|
105
98
|
return false;
|
|
106
99
|
};
|
|
100
|
+
// RFC 7628 section 3.2.2: with the OAuth provider the error result also says where to get a token and for which scope
|
|
101
|
+
const errorResult = (status) => server.oauth && server.oauth.issuer ? { status, scope: server.oauth.mailScope, 'openid-configuration': server.oauth.discoveryUrl() } : { status };
|
|
107
102
|
// Decodes a base64 client response, sends BAD and returns false if it is not valid base64
|
|
108
103
|
const decode = (connection, parsed, data, input) => {
|
|
109
104
|
const decoded = connection.decodeSaslResponse(input);
|
|
@@ -155,7 +150,7 @@ export default function oauthbearerPlugin(server) {
|
|
|
155
150
|
attributes: [
|
|
156
151
|
{
|
|
157
152
|
type: 'ATOM',
|
|
158
|
-
value: Buffer.from(JSON.stringify(
|
|
153
|
+
value: Buffer.from(JSON.stringify(errorResult(status))).toString('base64')
|
|
159
154
|
}
|
|
160
155
|
]
|
|
161
156
|
}, 'AUTHENTICATE OAUTHBEARER CHALLENGE', parsed, data);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* @help SASL-IR enabled and used while Gmail does not
|
|
6
6
|
* @help Valid login info:
|
|
7
7
|
* @help Username: testuser
|
|
8
|
-
* @help Access Token: testtoken
|
|
8
|
+
* @help Access Token: testtoken, or a token of the OAuth provider
|
|
9
9
|
*/
|
|
10
10
|
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
11
11
|
export default function xoauth2Plugin(server) {
|
|
@@ -91,7 +91,7 @@ export default function xoauth2Plugin(server) {
|
|
|
91
91
|
}, 'AUTHENTICATE XOAUTH2 FAILED', parsed, data);
|
|
92
92
|
return callback();
|
|
93
93
|
}
|
|
94
|
-
if (
|
|
94
|
+
if ('error' in server.checkAccessToken(accessToken, user)) {
|
|
95
95
|
connection.send({
|
|
96
96
|
tag: '+',
|
|
97
97
|
attributes: [
|
|
@@ -100,7 +100,8 @@ export default function xoauth2Plugin(server) {
|
|
|
100
100
|
value: Buffer.from(JSON.stringify({
|
|
101
101
|
status: '400',
|
|
102
102
|
schemes: 'Bearer',
|
|
103
|
-
scope
|
|
103
|
+
// the scope the OAuth provider of this server asks for, Gmail's without one
|
|
104
|
+
scope: server.oauth ? server.oauth.mailScope : 'https://mail.google.com/'
|
|
104
105
|
})).toString('base64')
|
|
105
106
|
}
|
|
106
107
|
]
|
package/dist/esm/rest.d.ts
CHANGED
|
@@ -41,6 +41,23 @@ declare function getRoutes(server: IMAPServer, streams?: Set<http.ServerResponse
|
|
|
41
41
|
* @return {Object} OpenAPI document
|
|
42
42
|
*/
|
|
43
43
|
declare function openApi(routes: Route[]): Record<string, unknown>;
|
|
44
|
+
/**
|
|
45
|
+
* Compares two strings in constant time, also when their lengths differ
|
|
46
|
+
*
|
|
47
|
+
* @param {String} a First
|
|
48
|
+
* @param {String} b Second
|
|
49
|
+
* @return {Boolean} true if equal
|
|
50
|
+
*/
|
|
51
|
+
declare function safeEqual(a: string, b: string): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Reads a request body
|
|
54
|
+
*
|
|
55
|
+
* @param {Object} req HTTP request
|
|
56
|
+
* @param {Number} limit Largest body in octets
|
|
57
|
+
* @param {Function} tooBig Makes the error for a larger body
|
|
58
|
+
* @return {Promise<Buffer>} the body
|
|
59
|
+
*/
|
|
60
|
+
declare function readRaw(req: http.IncomingMessage, limit: number, tooBig: () => Error): Promise<Buffer>;
|
|
44
61
|
/**
|
|
45
62
|
* Creates the HTTP server of the REST API, it does not listen yet
|
|
46
63
|
*
|
|
@@ -50,5 +67,5 @@ declare function openApi(routes: Route[]): Record<string, unknown>;
|
|
|
50
67
|
* @throws {Error} for a host that is not a loopback address without a token
|
|
51
68
|
*/
|
|
52
69
|
declare function createRestServer(server: IMAPServer, options: RestOptions): RestServer;
|
|
53
|
-
export { createRestServer, isLoopback, getRoutes, openApi };
|
|
70
|
+
export { createRestServer, isLoopback, safeEqual, readRaw, getRoutes, openApi };
|
|
54
71
|
export type { RestOptions, RestServer };
|
package/dist/esm/rest.js
CHANGED
|
@@ -60,8 +60,10 @@ function decodeBody(value, encoding) {
|
|
|
60
60
|
}
|
|
61
61
|
return encoding === 'base64' && typeof value === 'string' ? Buffer.from(value, 'base64') : value;
|
|
62
62
|
}
|
|
63
|
-
// the server events that GET /v1/events streams
|
|
64
|
-
const EVENT_TYPES = ['session', 'command', 'mailbox', 'expunge', 'flags', 'acl', 'script', 'reset'];
|
|
63
|
+
// the server events that GET /v1/events streams by default
|
|
64
|
+
const EVENT_TYPES = ['session', 'command', 'mailbox', 'append', 'expunge', 'flags', 'acl', 'script', 'reset', 'oauth'];
|
|
65
|
+
// event types that are streamed only when ?types= asks for them, the protocol trace of every session is a lot of data
|
|
66
|
+
const OPT_IN_EVENT_TYPES = ['trace'];
|
|
65
67
|
const sessionNumber = (origin) => (origin ? origin.sessionNumber : null);
|
|
66
68
|
/**
|
|
67
69
|
* Turns the arguments of a server event into JSON data: mailboxes become paths, messages UIDs, sessions numbers
|
|
@@ -76,8 +78,13 @@ function eventData(type, args) {
|
|
|
76
78
|
const { type: change, path, oldPath, origin } = args[0];
|
|
77
79
|
return { type: change, path, oldPath, origin: sessionNumber(origin) };
|
|
78
80
|
}
|
|
81
|
+
case 'append':
|
|
79
82
|
case 'expunge':
|
|
80
83
|
return { path: args[0].path, uids: args[1].map(message => message.uid), origin: sessionNumber(args[2]) };
|
|
84
|
+
case 'trace': {
|
|
85
|
+
const { session, direction, data } = args[0];
|
|
86
|
+
return { session, direction, data: Buffer.from(data).toString('base64'), encoding: 'base64' };
|
|
87
|
+
}
|
|
81
88
|
case 'flags':
|
|
82
89
|
return {
|
|
83
90
|
path: args[0].path,
|
|
@@ -152,13 +159,16 @@ function getRoutes(server, streams = new Set()) {
|
|
|
152
159
|
{
|
|
153
160
|
method: 'GET',
|
|
154
161
|
path: '/v1/events',
|
|
155
|
-
summary: 'Server events as Server-Sent Events (?types=session,command to choose: ' +
|
|
162
|
+
summary: 'Server events as Server-Sent Events (?types=session,command to choose: ' +
|
|
163
|
+
EVENT_TYPES.concat(OPT_IN_EVENT_TYPES).join(', ') +
|
|
164
|
+
', trace only when asked for)',
|
|
156
165
|
handler: ({ query }) => {
|
|
157
166
|
const wanted = query.get('types');
|
|
158
167
|
const types = wanted === null ? EVENT_TYPES : wanted.split(',');
|
|
159
|
-
const
|
|
168
|
+
const known = EVENT_TYPES.concat(OPT_IN_EVENT_TYPES);
|
|
169
|
+
const unknown = types.filter(type => known.indexOf(type) < 0);
|
|
160
170
|
if (unknown.length) {
|
|
161
|
-
throw storeError('Unknown event type ' + unknown.join(', ') + ', expected ' +
|
|
171
|
+
throw storeError('Unknown event type ' + unknown.join(', ') + ', expected ' + known.join(', '), 'INVALID');
|
|
162
172
|
}
|
|
163
173
|
return { stream: eventStream(server, types, streams) };
|
|
164
174
|
}
|
|
@@ -187,18 +197,18 @@ function getRoutes(server, streams = new Set()) {
|
|
|
187
197
|
{
|
|
188
198
|
method: 'POST',
|
|
189
199
|
path: '/v1/users',
|
|
190
|
-
summary: 'Adds a user ({ name, password, xoauth2 })',
|
|
200
|
+
summary: 'Adds a user ({ name, password, xoauth2, displayName, oauth })',
|
|
191
201
|
handler: ({ body }) => {
|
|
192
|
-
control.addUser(body.name, { password: body.password, xoauth2: body.xoauth2 });
|
|
202
|
+
control.addUser(body.name, { password: body.password, xoauth2: body.xoauth2, displayName: body.displayName, oauth: body.oauth });
|
|
193
203
|
return { status: 201, body: { name: body.name } };
|
|
194
204
|
}
|
|
195
205
|
},
|
|
196
206
|
{
|
|
197
207
|
method: 'PUT',
|
|
198
208
|
path: '/v1/users/{name}',
|
|
199
|
-
summary: 'Changes the password
|
|
209
|
+
summary: 'Changes the password, the XOAUTH2 token, the display name (displayName) or the OAuth settings ({ oauth: { consent } }) of a user',
|
|
200
210
|
handler: ({ params, body }) => {
|
|
201
|
-
control.updateUser(params.name, { password: body.password, xoauth2: body.xoauth2 });
|
|
211
|
+
control.updateUser(params.name, { password: body.password, xoauth2: body.xoauth2, displayName: body.displayName, oauth: body.oauth });
|
|
202
212
|
return { name: params.name };
|
|
203
213
|
}
|
|
204
214
|
},
|
|
@@ -439,51 +449,67 @@ function matchRoute(routes, method, pathname) {
|
|
|
439
449
|
*/
|
|
440
450
|
function hasToken(header, token) {
|
|
441
451
|
const match = /^Bearer (.+)$/i.exec(header || '');
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
452
|
+
return !!match && safeEqual(match[1], token);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Compares two strings in constant time, also when their lengths differ
|
|
456
|
+
*
|
|
457
|
+
* @param {String} a First
|
|
458
|
+
* @param {String} b Second
|
|
459
|
+
* @return {Boolean} true if equal
|
|
460
|
+
*/
|
|
461
|
+
function safeEqual(a, b) {
|
|
445
462
|
const digest = (value) => crypto.createHash('sha256').update(value).digest();
|
|
446
|
-
return crypto.timingSafeEqual(digest(
|
|
463
|
+
return crypto.timingSafeEqual(digest(a), digest(b));
|
|
447
464
|
}
|
|
448
465
|
/**
|
|
449
|
-
* Reads a
|
|
466
|
+
* Reads a request body
|
|
450
467
|
*
|
|
451
468
|
* @param {Object} req HTTP request
|
|
452
|
-
* @
|
|
469
|
+
* @param {Number} limit Largest body in octets
|
|
470
|
+
* @param {Function} tooBig Makes the error for a larger body
|
|
471
|
+
* @return {Promise<Buffer>} the body
|
|
453
472
|
*/
|
|
454
|
-
function
|
|
473
|
+
function readRaw(req, limit, tooBig) {
|
|
455
474
|
return new Promise((resolve, reject) => {
|
|
456
475
|
const chunks = [];
|
|
457
476
|
let size = 0;
|
|
458
477
|
req.on('data', (chunk) => {
|
|
459
478
|
size += chunk.length;
|
|
460
|
-
if (size >
|
|
461
|
-
reject(
|
|
479
|
+
if (size > limit) {
|
|
480
|
+
reject(tooBig());
|
|
462
481
|
req.destroy();
|
|
463
482
|
return;
|
|
464
483
|
}
|
|
465
484
|
chunks.push(chunk);
|
|
466
485
|
});
|
|
467
486
|
req.on('error', reject);
|
|
468
|
-
req.on('end', () =>
|
|
469
|
-
const text = Buffer.concat(chunks).toString('utf-8');
|
|
470
|
-
if (!text) {
|
|
471
|
-
return resolve({});
|
|
472
|
-
}
|
|
473
|
-
let body;
|
|
474
|
-
try {
|
|
475
|
-
body = JSON.parse(text);
|
|
476
|
-
}
|
|
477
|
-
catch {
|
|
478
|
-
return reject(storeError('Request body is not valid JSON', 'INVALID'));
|
|
479
|
-
}
|
|
480
|
-
if (!body || typeof body !== 'object') {
|
|
481
|
-
return reject(storeError('Request body must be a JSON object or array', 'INVALID'));
|
|
482
|
-
}
|
|
483
|
-
resolve(body);
|
|
484
|
-
});
|
|
487
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
485
488
|
});
|
|
486
489
|
}
|
|
490
|
+
/**
|
|
491
|
+
* Reads a JSON request body
|
|
492
|
+
*
|
|
493
|
+
* @param {Object} req HTTP request
|
|
494
|
+
* @return {Promise<Object>} the body, an empty object without one
|
|
495
|
+
*/
|
|
496
|
+
async function readBody(req) {
|
|
497
|
+
const text = (await readRaw(req, BODY_LIMIT, () => storeError('Request body is larger than 64 MiB', 'TOOBIG'))).toString('utf-8');
|
|
498
|
+
if (!text) {
|
|
499
|
+
return {};
|
|
500
|
+
}
|
|
501
|
+
let body;
|
|
502
|
+
try {
|
|
503
|
+
body = JSON.parse(text);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
throw storeError('Request body is not valid JSON', 'INVALID');
|
|
507
|
+
}
|
|
508
|
+
if (!body || typeof body !== 'object') {
|
|
509
|
+
throw storeError('Request body must be a JSON object or array', 'INVALID');
|
|
510
|
+
}
|
|
511
|
+
return body;
|
|
512
|
+
}
|
|
487
513
|
/**
|
|
488
514
|
* Creates the HTTP server of the REST API, it does not listen yet
|
|
489
515
|
*
|
|
@@ -556,4 +582,4 @@ function createRestServer(server, options) {
|
|
|
556
582
|
rest.endStreams = () => streams.forEach(res => res.end());
|
|
557
583
|
return rest;
|
|
558
584
|
}
|
|
559
|
-
export { createRestServer, isLoopback, getRoutes, openApi };
|
|
585
|
+
export { createRestServer, isLoopback, safeEqual, readRaw, getRoutes, openApi };
|
package/dist/esm/server.d.ts
CHANGED
|
@@ -5,6 +5,9 @@ import type { CompilerOptions, ParserOptions } from 'imap-handler';
|
|
|
5
5
|
import type { ResolvedCommandOptions } from './command-states.js';
|
|
6
6
|
import { Control } from './control.js';
|
|
7
7
|
import type { RestServer } from './rest.js';
|
|
8
|
+
import { OAuthProvider } from './oauth/provider.js';
|
|
9
|
+
import type { TokenCheck } from './oauth/provider.js';
|
|
10
|
+
import type http from 'node:http';
|
|
8
11
|
import type { SessionInfo } from './control.js';
|
|
9
12
|
import type { MemorySocket } from './memory-socket.js';
|
|
10
13
|
import { ServerScript } from './script.js';
|
|
@@ -83,10 +86,16 @@ declare class IMAPServer extends Stream {
|
|
|
83
86
|
sessionCounter: number;
|
|
84
87
|
/** the control API, see src/control.ts */
|
|
85
88
|
control: Control;
|
|
89
|
+
/** the implicit TLS listener that `start()` runs for the `tls` option */
|
|
90
|
+
tlsServer: tls.Server | null;
|
|
86
91
|
/** the SMTP server that `start()` runs for the `smtp` option */
|
|
87
92
|
smtpServer: SMTPListener | null;
|
|
88
93
|
/** the HTTP server of the REST API that `start()` runs for the `rest` option */
|
|
89
94
|
restServer: RestServer | null;
|
|
95
|
+
/** the OAuth 2.0 / OpenID Connect provider of the `oauth` option, see src/oauth/provider.ts */
|
|
96
|
+
oauth: OAuthProvider | null;
|
|
97
|
+
/** the HTTP server of the OAuth provider that `start()` runs */
|
|
98
|
+
oauthServer: http.Server | null;
|
|
90
99
|
constructor(options?: IMAPServerOptions);
|
|
91
100
|
/**
|
|
92
101
|
* Sets the users from the `users` option, or the default user. The users are deep copied, so that runtime changes
|
|
@@ -107,12 +116,37 @@ declare class IMAPServer extends Stream {
|
|
|
107
116
|
* @return {Promise<Number>} resolves with the port the server listens on
|
|
108
117
|
*/
|
|
109
118
|
start(port?: number, host?: string): Promise<number>;
|
|
119
|
+
/**
|
|
120
|
+
* Starts the implicit TLS listener of the `tls` option. Its sessions are sessions of this server like the ones of
|
|
121
|
+
* the main listener, they share the storage and count as encrypted
|
|
122
|
+
*
|
|
123
|
+
* @param {String} [defaultHost] Address to listen on when the `tls` option has no host
|
|
124
|
+
* @return {Promise<Number>} resolves with the port of the TLS listener
|
|
125
|
+
*/
|
|
126
|
+
startTlsListener(defaultHost?: string): Promise<number>;
|
|
110
127
|
/**
|
|
111
128
|
* Starts the REST API of the `rest` option, see src/rest.ts
|
|
112
129
|
*
|
|
113
130
|
* @return {Promise<Number>} resolves with the HTTP port
|
|
114
131
|
*/
|
|
115
132
|
startRest(): Promise<number>;
|
|
133
|
+
/**
|
|
134
|
+
* Starts the HTTP server of the OAuth provider (the `oauth` option, see src/oauth/http.ts). The issuer is its
|
|
135
|
+
* URL unless the `issuer` option sets another
|
|
136
|
+
*
|
|
137
|
+
* @return {Promise<Number>} resolves with the HTTP port
|
|
138
|
+
*/
|
|
139
|
+
startOAuth(): Promise<number>;
|
|
140
|
+
/**
|
|
141
|
+
* Checks a bearer token of an IMAP or SMTP login: a token of the OAuth provider, or the static
|
|
142
|
+
* `xoauth2.accessToken` of a user, which never expires
|
|
143
|
+
*
|
|
144
|
+
* @param {String} token Access token
|
|
145
|
+
* @param {String} [username] The user the client logs in as, the owner of the token if not set
|
|
146
|
+
* @param {String} [via] "imap" or "smtp", for the events of the provider
|
|
147
|
+
* @return {Object} `{ user }`, or `{ error, reason }` with the OAuth error code (invalid_token, insufficient_scope)
|
|
148
|
+
*/
|
|
149
|
+
checkAccessToken(token: string, username?: string, via?: string): TokenCheck;
|
|
116
150
|
/**
|
|
117
151
|
* Starts the SMTP server of the `smtp` option. smtp-server is an optional dependency, loaded only here
|
|
118
152
|
*
|
|
@@ -141,7 +175,14 @@ declare class IMAPServer extends Stream {
|
|
|
141
175
|
listen(options: net.ListenOptions, listeningListener?: () => void): void;
|
|
142
176
|
close(callback?: (err?: Error) => void): void;
|
|
143
177
|
/**
|
|
144
|
-
* Stops the
|
|
178
|
+
* Stops accepting IMAP connections, on the main listener and on the TLS listener of the `tls` option
|
|
179
|
+
*
|
|
180
|
+
* @param {Function} [callback] Called once both listeners are closed, which is after their last connection is
|
|
181
|
+
* gone, with the first error
|
|
182
|
+
*/
|
|
183
|
+
closeImapListeners(callback?: (err?: Error) => void): void;
|
|
184
|
+
/**
|
|
185
|
+
* Stops the SMTP, REST and OAuth listeners of `start()`
|
|
145
186
|
*
|
|
146
187
|
* @param {Boolean} force true closes the open REST connections too, otherwise only the idle ones (a request that
|
|
147
188
|
* is answered, like POST /v1/shutdown, finishes first)
|
|
@@ -677,6 +718,13 @@ declare class IMAPConnection {
|
|
|
677
718
|
* @param {Function} [callback] Called once the data was handed to the system (a transport: on the next turn)
|
|
678
719
|
*/
|
|
679
720
|
writeLayer(data: Buffer, transport: Transport | null, callback?: () => void): void;
|
|
721
|
+
/**
|
|
722
|
+
* Emits the protocol traffic of the session as `trace` events, as the IMAP layer sees it: above TLS and COMPRESS
|
|
723
|
+
*
|
|
724
|
+
* @param {String} direction "in" for client input, "out" for server output
|
|
725
|
+
* @param {Buffer} data The octets
|
|
726
|
+
*/
|
|
727
|
+
trace(direction: 'in' | 'out', data: Buffer): void;
|
|
680
728
|
/**
|
|
681
729
|
* Sends output through the script rule that handles its event, if there is one
|
|
682
730
|
*
|
package/dist/esm/server.js
CHANGED
|
@@ -14,10 +14,12 @@ import { isSequenceSet } from './numbers.js';
|
|
|
14
14
|
import { restoreNilAtoms, isAtom } from './arguments.js';
|
|
15
15
|
import { refuseMissingTarget } from './commands/append.js';
|
|
16
16
|
import { DEFAULT_SESSION_TIMEOUT, storeError, expungeMessages, notifyFlagChanges } from './store-operations.js';
|
|
17
|
-
import { Control } from './control.js';
|
|
17
|
+
import { Control, checkUserOAuth } from './control.js';
|
|
18
18
|
import { resolveQuirks } from './quirks.js';
|
|
19
19
|
import { validateStorage } from './storage-schema.js';
|
|
20
20
|
import { createRestServer } from './rest.js';
|
|
21
|
+
import { OAuthProvider } from './oauth/provider.js';
|
|
22
|
+
import { createOAuthServer } from './oauth/http.js';
|
|
21
23
|
import * as bundledCert from './cert.js';
|
|
22
24
|
import { createSocketPair } from './memory-socket.js';
|
|
23
25
|
import { ServerScript, sendOutput, handleLine, handleQuiet, literalResponse, releaseDeferred } from './script.js';
|
|
@@ -193,6 +195,9 @@ class IMAPServer extends Stream {
|
|
|
193
195
|
this.loadUsers();
|
|
194
196
|
// plugins register their control operations
|
|
195
197
|
this.control = new Control(this);
|
|
198
|
+
// registers its control operations too, so it exists only with the option
|
|
199
|
+
this.oauth = this.options.oauth ? new OAuthProvider(this, this.options.oauth) : null;
|
|
200
|
+
this.oauthServer = null;
|
|
196
201
|
// a quirk preset can leave plugins out (no-move, no-uidplus)
|
|
197
202
|
loadPlugins(this, this.options.plugins, quirks.removePlugins);
|
|
198
203
|
if (this.options.storage) {
|
|
@@ -201,6 +206,7 @@ class IMAPServer extends Stream {
|
|
|
201
206
|
}
|
|
202
207
|
this.systemFlags = [].concat(this.options.systemFlags || ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen']);
|
|
203
208
|
this.loadStorage();
|
|
209
|
+
this.tlsServer = null;
|
|
204
210
|
this.smtpServer = null;
|
|
205
211
|
this.restServer = null;
|
|
206
212
|
}
|
|
@@ -221,6 +227,13 @@ class IMAPServer extends Stream {
|
|
|
221
227
|
}
|
|
222
228
|
}
|
|
223
229
|
});
|
|
230
|
+
// a typo in an OAuth consent policy fails here
|
|
231
|
+
for (const name of Object.keys(this.users)) {
|
|
232
|
+
const user = this.users[name];
|
|
233
|
+
if (user && user.oauth !== undefined) {
|
|
234
|
+
user.oauth = checkUserOAuth(user.oauth, 'users[' + JSON.stringify(name) + '].oauth');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
224
237
|
}
|
|
225
238
|
/**
|
|
226
239
|
* Builds the mailboxes from the `storage` option, a deep copy of it. Message and mailbox handlers of plugins run
|
|
@@ -251,9 +264,26 @@ class IMAPServer extends Stream {
|
|
|
251
264
|
*/
|
|
252
265
|
async start(port, host) {
|
|
253
266
|
const imapPort = await listenOn(this.server, port, host);
|
|
254
|
-
await Promise.all([
|
|
267
|
+
await Promise.all([
|
|
268
|
+
this.options.tls ? this.startTlsListener(host) : null,
|
|
269
|
+
this.options.smtp ? this.startSmtp() : null,
|
|
270
|
+
this.options.rest ? this.startRest() : null,
|
|
271
|
+
this.oauth ? this.startOAuth() : null
|
|
272
|
+
]);
|
|
255
273
|
return imapPort;
|
|
256
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* Starts the implicit TLS listener of the `tls` option. Its sessions are sessions of this server like the ones of
|
|
277
|
+
* the main listener, they share the storage and count as encrypted
|
|
278
|
+
*
|
|
279
|
+
* @param {String} [defaultHost] Address to listen on when the `tls` option has no host
|
|
280
|
+
* @return {Promise<Number>} resolves with the port of the TLS listener
|
|
281
|
+
*/
|
|
282
|
+
startTlsListener(defaultHost) {
|
|
283
|
+
const { port, host } = this.options.tls || {};
|
|
284
|
+
this.tlsServer = tls.createServer(this.getCredentials(), this.createClient.bind(this));
|
|
285
|
+
return listenOn(this.tlsServer, port, host || defaultHost);
|
|
286
|
+
}
|
|
257
287
|
/**
|
|
258
288
|
* Starts the REST API of the `rest` option, see src/rest.ts
|
|
259
289
|
*
|
|
@@ -264,6 +294,41 @@ class IMAPServer extends Stream {
|
|
|
264
294
|
this.restServer = createRestServer(this, { host, token });
|
|
265
295
|
return listenOn(this.restServer, port, host || '127.0.0.1');
|
|
266
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Starts the HTTP server of the OAuth provider (the `oauth` option, see src/oauth/http.ts). The issuer is its
|
|
299
|
+
* URL unless the `issuer` option sets another
|
|
300
|
+
*
|
|
301
|
+
* @return {Promise<Number>} resolves with the HTTP port
|
|
302
|
+
*/
|
|
303
|
+
async startOAuth() {
|
|
304
|
+
const oauth = this.oauth;
|
|
305
|
+
const { port, host = '127.0.0.1', secure } = oauth.options;
|
|
306
|
+
this.oauthServer = createOAuthServer(oauth, { secure, host, credentials: this.getCredentials() });
|
|
307
|
+
const listening = await listenOn(this.oauthServer, port, host);
|
|
308
|
+
oauth.listening(host, listening);
|
|
309
|
+
return listening;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Checks a bearer token of an IMAP or SMTP login: a token of the OAuth provider, or the static
|
|
313
|
+
* `xoauth2.accessToken` of a user, which never expires
|
|
314
|
+
*
|
|
315
|
+
* @param {String} token Access token
|
|
316
|
+
* @param {String} [username] The user the client logs in as, the owner of the token if not set
|
|
317
|
+
* @param {String} [via] "imap" or "smtp", for the events of the provider
|
|
318
|
+
* @return {Object} `{ user }`, or `{ error, reason }` with the OAuth error code (invalid_token, insufficient_scope)
|
|
319
|
+
*/
|
|
320
|
+
checkAccessToken(token, username, via = 'imap') {
|
|
321
|
+
const issued = this.oauth && this.oauth.verifyAccessToken(token, username, via);
|
|
322
|
+
if (issued) {
|
|
323
|
+
return issued;
|
|
324
|
+
}
|
|
325
|
+
const owns = (name) => {
|
|
326
|
+
const user = this.users[name];
|
|
327
|
+
return !!(user && user.xoauth2 && user.xoauth2.accessToken === token);
|
|
328
|
+
};
|
|
329
|
+
const user = username === undefined ? Object.keys(this.users).find(owns) : owns(username) ? username : undefined;
|
|
330
|
+
return user === undefined ? { error: 'invalid_token', reason: 'unknown access token' } : { user };
|
|
331
|
+
}
|
|
267
332
|
/**
|
|
268
333
|
* Starts the SMTP server of the `smtp` option. smtp-server is an optional dependency, loaded only here
|
|
269
334
|
*
|
|
@@ -303,7 +368,7 @@ class IMAPServer extends Stream {
|
|
|
303
368
|
}
|
|
304
369
|
close(callback) {
|
|
305
370
|
this.closeListeners(true);
|
|
306
|
-
this.
|
|
371
|
+
this.closeImapListeners(callback);
|
|
307
372
|
// close() only completes once all connections are gone
|
|
308
373
|
this.connections.forEach((connection) => {
|
|
309
374
|
if (connection.socket) {
|
|
@@ -312,7 +377,25 @@ class IMAPServer extends Stream {
|
|
|
312
377
|
});
|
|
313
378
|
}
|
|
314
379
|
/**
|
|
315
|
-
* Stops the
|
|
380
|
+
* Stops accepting IMAP connections, on the main listener and on the TLS listener of the `tls` option
|
|
381
|
+
*
|
|
382
|
+
* @param {Function} [callback] Called once both listeners are closed, which is after their last connection is
|
|
383
|
+
* gone, with the first error
|
|
384
|
+
*/
|
|
385
|
+
closeImapListeners(callback) {
|
|
386
|
+
const listeners = this.tlsServer ? [this.server, this.tlsServer] : [this.server];
|
|
387
|
+
this.tlsServer = null;
|
|
388
|
+
let pending = listeners.length;
|
|
389
|
+
let error;
|
|
390
|
+
listeners.forEach(listener => listener.close(err => {
|
|
391
|
+
error = error || err;
|
|
392
|
+
if (--pending === 0) {
|
|
393
|
+
callback?.(error);
|
|
394
|
+
}
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Stops the SMTP, REST and OAuth listeners of `start()`
|
|
316
399
|
*
|
|
317
400
|
* @param {Boolean} force true closes the open REST connections too, otherwise only the idle ones (a request that
|
|
318
401
|
* is answered, like POST /v1/shutdown, finishes first)
|
|
@@ -325,16 +408,13 @@ class IMAPServer extends Stream {
|
|
|
325
408
|
if (this.restServer) {
|
|
326
409
|
// event streams never end on their own, a graceful shutdown would wait for them forever
|
|
327
410
|
this.restServer.endStreams?.();
|
|
328
|
-
this.restServer
|
|
329
|
-
// keep-alive connections would hold the server open
|
|
330
|
-
if (force) {
|
|
331
|
-
this.restServer.closeAllConnections?.();
|
|
332
|
-
}
|
|
333
|
-
else {
|
|
334
|
-
this.restServer.closeIdleConnections?.();
|
|
335
|
-
}
|
|
411
|
+
closeHttp(this.restServer, force);
|
|
336
412
|
this.restServer = null;
|
|
337
413
|
}
|
|
414
|
+
if (this.oauthServer) {
|
|
415
|
+
closeHttp(this.oauthServer, force);
|
|
416
|
+
this.oauthServer = null;
|
|
417
|
+
}
|
|
338
418
|
}
|
|
339
419
|
/**
|
|
340
420
|
* Returns TLS key and certificate. Without credentials in the options the
|
|
@@ -429,7 +509,8 @@ class IMAPServer extends Stream {
|
|
|
429
509
|
* @return {Object|false} User data or false if there is no such user
|
|
430
510
|
*/
|
|
431
511
|
getUser(username) {
|
|
432
|
-
|
|
512
|
+
// users is a null-prototype object, but a plain object from the control API or a test may still be assigned
|
|
513
|
+
return (typeof username === 'string' && Object.hasOwn(this.users, username) && this.users[username]) || false;
|
|
433
514
|
}
|
|
434
515
|
/**
|
|
435
516
|
* Returns a mailbox object from folderCache
|
|
@@ -1111,6 +1192,8 @@ class IMAPServer extends Stream {
|
|
|
1111
1192
|
break;
|
|
1112
1193
|
}
|
|
1113
1194
|
}
|
|
1195
|
+
// like expunge, before the sessions are told: APPEND, COPY, MOVE, REPLACE, SMTP and the control API all add here
|
|
1196
|
+
this.emit('append', mailbox, [message], this.activeConnection);
|
|
1114
1197
|
this.notify({
|
|
1115
1198
|
tag: '*',
|
|
1116
1199
|
attributes: [
|
|
@@ -1554,6 +1637,7 @@ class IMAPConnection {
|
|
|
1554
1637
|
* @param {Function} [callback] Called once the data was handed to the system (a transport: on the next turn)
|
|
1555
1638
|
*/
|
|
1556
1639
|
writeLayer(data, transport, callback) {
|
|
1640
|
+
this.trace('out', data);
|
|
1557
1641
|
if (transport) {
|
|
1558
1642
|
transport.write(data);
|
|
1559
1643
|
if (callback) {
|
|
@@ -1564,6 +1648,17 @@ class IMAPConnection {
|
|
|
1564
1648
|
this.writeRaw(data, callback);
|
|
1565
1649
|
}
|
|
1566
1650
|
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Emits the protocol traffic of the session as `trace` events, as the IMAP layer sees it: above TLS and COMPRESS
|
|
1653
|
+
*
|
|
1654
|
+
* @param {String} direction "in" for client input, "out" for server output
|
|
1655
|
+
* @param {Buffer} data The octets
|
|
1656
|
+
*/
|
|
1657
|
+
trace(direction, data) {
|
|
1658
|
+
if (this.server.listenerCount('trace')) {
|
|
1659
|
+
this.server.emit('trace', { session: this.sessionNumber, direction, data });
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1567
1662
|
/**
|
|
1568
1663
|
* Sends output through the script rule that handles its event, if there is one
|
|
1569
1664
|
*
|
|
@@ -1874,6 +1969,9 @@ class IMAPConnection {
|
|
|
1874
1969
|
if (this._resetting) {
|
|
1875
1970
|
return;
|
|
1876
1971
|
}
|
|
1972
|
+
if (chunk && chunk.length) {
|
|
1973
|
+
this.trace('in', chunk);
|
|
1974
|
+
}
|
|
1877
1975
|
// everything in one read arrived before anything this read causes to be sent
|
|
1878
1976
|
this._readCount = (this._readCount || 0) + 1;
|
|
1879
1977
|
str = (chunk || '').toString('binary');
|
|
@@ -3169,6 +3267,22 @@ const IS_DENO = 'Deno' in globalThis;
|
|
|
3169
3267
|
function nextTurn(fn) {
|
|
3170
3268
|
return IS_DENO ? setTimeout(fn, 0) : setImmediate(fn);
|
|
3171
3269
|
}
|
|
3270
|
+
/**
|
|
3271
|
+
* Stops an HTTP listener of `start()`
|
|
3272
|
+
*
|
|
3273
|
+
* @param {Object} server HTTP server
|
|
3274
|
+
* @param {Boolean} force true closes the open connections too, otherwise only the idle ones (keep-alive connections
|
|
3275
|
+
* would hold the server open)
|
|
3276
|
+
*/
|
|
3277
|
+
function closeHttp(server, force) {
|
|
3278
|
+
server.close();
|
|
3279
|
+
if (force) {
|
|
3280
|
+
server.closeAllConnections?.();
|
|
3281
|
+
}
|
|
3282
|
+
else {
|
|
3283
|
+
server.closeIdleConnections?.();
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3172
3286
|
/**
|
|
3173
3287
|
* Starts listening, resolves with the port or rejects with the listen error (EADDRINUSE)
|
|
3174
3288
|
*
|
|
@@ -2,8 +2,9 @@ import { SMTPServer } from 'smtp-server';
|
|
|
2
2
|
import type { IMAPServer } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Starts an SMTP server that appends every received message to the INBOX
|
|
5
|
-
* of the given ImapKit IMAP server. Any sender, recipient and
|
|
6
|
-
*
|
|
5
|
+
* of the given ImapKit IMAP server. Any sender, recipient and password are
|
|
6
|
+
* accepted. With the OAuth provider, AUTH XOAUTH2 is offered too and checks
|
|
7
|
+
* the access token like the IMAP login does.
|
|
7
8
|
*
|
|
8
9
|
* @param {Number} smtpPort - port to listen on for SMTP commands
|
|
9
10
|
* @param {Object} imapServer - the ImapKit IMAP server
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { SMTPServer } from 'smtp-server';
|
|
2
2
|
/**
|
|
3
3
|
* Starts an SMTP server that appends every received message to the INBOX
|
|
4
|
-
* of the given ImapKit IMAP server. Any sender, recipient and
|
|
5
|
-
*
|
|
4
|
+
* of the given ImapKit IMAP server. Any sender, recipient and password are
|
|
5
|
+
* accepted. With the OAuth provider, AUTH XOAUTH2 is offered too and checks
|
|
6
|
+
* the access token like the IMAP login does.
|
|
6
7
|
*
|
|
7
8
|
* @param {Number} smtpPort - port to listen on for SMTP commands
|
|
8
9
|
* @param {Object} imapServer - the ImapKit IMAP server
|
|
@@ -17,9 +18,20 @@ export function startSMTPServer(smtpPort, imapServer, callback, host) {
|
|
|
17
18
|
key: credentials.key,
|
|
18
19
|
cert: credentials.cert,
|
|
19
20
|
authOptional: true,
|
|
21
|
+
// with the OAuth provider, clients can also log in with its access tokens
|
|
22
|
+
authMethods: imapServer.oauth ? ['PLAIN', 'LOGIN', 'XOAUTH2'] : ['PLAIN', 'LOGIN'],
|
|
20
23
|
logger: false,
|
|
21
24
|
onAuth(auth, session, done) {
|
|
22
|
-
|
|
25
|
+
// XOAUTH2 is only offered with the OAuth provider
|
|
26
|
+
if (auth.method !== 'XOAUTH2') {
|
|
27
|
+
return done(null, { user: auth.username });
|
|
28
|
+
}
|
|
29
|
+
const result = imapServer.checkAccessToken(auth.accessToken || '', auth.username, 'smtp');
|
|
30
|
+
if ('error' in result) {
|
|
31
|
+
// the JSON error of Gmail's XOAUTH2, the client answers it and gets 535
|
|
32
|
+
return done(null, { data: { status: '401', schemes: 'bearer', scope: imapServer.oauth.mailScope } });
|
|
33
|
+
}
|
|
34
|
+
done(null, { user: result.user });
|
|
23
35
|
},
|
|
24
36
|
onData(stream, session, done) {
|
|
25
37
|
const chunks = [];
|