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
|
@@ -7,7 +7,7 @@ exports.parseClientResponse = parseClientResponse;
|
|
|
7
7
|
* @help Works with and without SASL-IR [RFC4959]
|
|
8
8
|
* @help Valid login info, same as for XOAUTH2:
|
|
9
9
|
* @help Username (authzid, optional): testuser
|
|
10
|
-
* @help Access Token: testtoken
|
|
10
|
+
* @help Access Token: testtoken, or a token of the OAuth provider
|
|
11
11
|
*/
|
|
12
12
|
const KVSEP = '\x01';
|
|
13
13
|
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
@@ -93,21 +93,16 @@ function oauthbearerPlugin(server) {
|
|
|
93
93
|
return auth === '' ? 'invalid_token' : 'invalid_request';
|
|
94
94
|
}
|
|
95
95
|
const token = bearer[1];
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
const user = server.getUser(username);
|
|
105
|
-
if (!user || !user.xoauth2 || user.xoauth2.accessToken !== token) {
|
|
106
|
-
return 'invalid_token';
|
|
107
|
-
}
|
|
108
|
-
request.username = username;
|
|
96
|
+
// without an authzid the token tells who the user is
|
|
97
|
+
const result = server.checkAccessToken(token, request.authzid === false ? undefined : request.authzid);
|
|
98
|
+
if ('error' in result) {
|
|
99
|
+
return result.error;
|
|
100
|
+
}
|
|
101
|
+
request.username = result.user;
|
|
109
102
|
return false;
|
|
110
103
|
};
|
|
104
|
+
// RFC 7628 section 3.2.2: with the OAuth provider the error result also says where to get a token and for which scope
|
|
105
|
+
const errorResult = (status) => server.oauth && server.oauth.issuer ? { status, scope: server.oauth.mailScope, 'openid-configuration': server.oauth.discoveryUrl() } : { status };
|
|
111
106
|
// Decodes a base64 client response, sends BAD and returns false if it is not valid base64
|
|
112
107
|
const decode = (connection, parsed, data, input) => {
|
|
113
108
|
const decoded = connection.decodeSaslResponse(input);
|
|
@@ -159,7 +154,7 @@ function oauthbearerPlugin(server) {
|
|
|
159
154
|
attributes: [
|
|
160
155
|
{
|
|
161
156
|
type: 'ATOM',
|
|
162
|
-
value: Buffer.from(JSON.stringify(
|
|
157
|
+
value: Buffer.from(JSON.stringify(errorResult(status))).toString('base64')
|
|
163
158
|
}
|
|
164
159
|
]
|
|
165
160
|
}, 'AUTHENTICATE OAUTHBEARER CHALLENGE', parsed, data);
|
|
@@ -8,7 +8,7 @@ exports.default = xoauth2Plugin;
|
|
|
8
8
|
* @help SASL-IR enabled and used while Gmail does not
|
|
9
9
|
* @help Valid login info:
|
|
10
10
|
* @help Username: testuser
|
|
11
|
-
* @help Access Token: testtoken
|
|
11
|
+
* @help Access Token: testtoken, or a token of the OAuth provider
|
|
12
12
|
*/
|
|
13
13
|
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
14
14
|
function xoauth2Plugin(server) {
|
|
@@ -94,7 +94,7 @@ function xoauth2Plugin(server) {
|
|
|
94
94
|
}, 'AUTHENTICATE XOAUTH2 FAILED', parsed, data);
|
|
95
95
|
return callback();
|
|
96
96
|
}
|
|
97
|
-
if (
|
|
97
|
+
if ('error' in server.checkAccessToken(accessToken, user)) {
|
|
98
98
|
connection.send({
|
|
99
99
|
tag: '+',
|
|
100
100
|
attributes: [
|
|
@@ -103,7 +103,8 @@ function xoauth2Plugin(server) {
|
|
|
103
103
|
value: Buffer.from(JSON.stringify({
|
|
104
104
|
status: '400',
|
|
105
105
|
schemes: 'Bearer',
|
|
106
|
-
scope
|
|
106
|
+
// the scope the OAuth provider of this server asks for, Gmail's without one
|
|
107
|
+
scope: server.oauth ? server.oauth.mailScope : 'https://mail.google.com/'
|
|
107
108
|
})).toString('base64')
|
|
108
109
|
}
|
|
109
110
|
]
|
package/dist/cjs/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/cjs/rest.js
CHANGED
|
@@ -12,6 +12,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
13
|
exports.createRestServer = createRestServer;
|
|
14
14
|
exports.isLoopback = isLoopback;
|
|
15
|
+
exports.safeEqual = safeEqual;
|
|
16
|
+
exports.readRaw = readRaw;
|
|
15
17
|
exports.getRoutes = getRoutes;
|
|
16
18
|
exports.openApi = openApi;
|
|
17
19
|
const node_http_1 = __importDefault(require("node:http"));
|
|
@@ -69,8 +71,10 @@ function decodeBody(value, encoding) {
|
|
|
69
71
|
}
|
|
70
72
|
return encoding === 'base64' && typeof value === 'string' ? Buffer.from(value, 'base64') : value;
|
|
71
73
|
}
|
|
72
|
-
// the server events that GET /v1/events streams
|
|
73
|
-
const EVENT_TYPES = ['session', 'command', 'mailbox', 'expunge', 'flags', 'acl', 'script', 'reset'];
|
|
74
|
+
// the server events that GET /v1/events streams by default
|
|
75
|
+
const EVENT_TYPES = ['session', 'command', 'mailbox', 'append', 'expunge', 'flags', 'acl', 'script', 'reset', 'oauth'];
|
|
76
|
+
// event types that are streamed only when ?types= asks for them, the protocol trace of every session is a lot of data
|
|
77
|
+
const OPT_IN_EVENT_TYPES = ['trace'];
|
|
74
78
|
const sessionNumber = (origin) => (origin ? origin.sessionNumber : null);
|
|
75
79
|
/**
|
|
76
80
|
* Turns the arguments of a server event into JSON data: mailboxes become paths, messages UIDs, sessions numbers
|
|
@@ -85,8 +89,13 @@ function eventData(type, args) {
|
|
|
85
89
|
const { type: change, path, oldPath, origin } = args[0];
|
|
86
90
|
return { type: change, path, oldPath, origin: sessionNumber(origin) };
|
|
87
91
|
}
|
|
92
|
+
case 'append':
|
|
88
93
|
case 'expunge':
|
|
89
94
|
return { path: args[0].path, uids: args[1].map(message => message.uid), origin: sessionNumber(args[2]) };
|
|
95
|
+
case 'trace': {
|
|
96
|
+
const { session, direction, data } = args[0];
|
|
97
|
+
return { session, direction, data: Buffer.from(data).toString('base64'), encoding: 'base64' };
|
|
98
|
+
}
|
|
90
99
|
case 'flags':
|
|
91
100
|
return {
|
|
92
101
|
path: args[0].path,
|
|
@@ -161,13 +170,16 @@ function getRoutes(server, streams = new Set()) {
|
|
|
161
170
|
{
|
|
162
171
|
method: 'GET',
|
|
163
172
|
path: '/v1/events',
|
|
164
|
-
summary: 'Server events as Server-Sent Events (?types=session,command to choose: ' +
|
|
173
|
+
summary: 'Server events as Server-Sent Events (?types=session,command to choose: ' +
|
|
174
|
+
EVENT_TYPES.concat(OPT_IN_EVENT_TYPES).join(', ') +
|
|
175
|
+
', trace only when asked for)',
|
|
165
176
|
handler: ({ query }) => {
|
|
166
177
|
const wanted = query.get('types');
|
|
167
178
|
const types = wanted === null ? EVENT_TYPES : wanted.split(',');
|
|
168
|
-
const
|
|
179
|
+
const known = EVENT_TYPES.concat(OPT_IN_EVENT_TYPES);
|
|
180
|
+
const unknown = types.filter(type => known.indexOf(type) < 0);
|
|
169
181
|
if (unknown.length) {
|
|
170
|
-
throw (0, store_operations_js_1.storeError)('Unknown event type ' + unknown.join(', ') + ', expected ' +
|
|
182
|
+
throw (0, store_operations_js_1.storeError)('Unknown event type ' + unknown.join(', ') + ', expected ' + known.join(', '), 'INVALID');
|
|
171
183
|
}
|
|
172
184
|
return { stream: eventStream(server, types, streams) };
|
|
173
185
|
}
|
|
@@ -196,18 +208,18 @@ function getRoutes(server, streams = new Set()) {
|
|
|
196
208
|
{
|
|
197
209
|
method: 'POST',
|
|
198
210
|
path: '/v1/users',
|
|
199
|
-
summary: 'Adds a user ({ name, password, xoauth2 })',
|
|
211
|
+
summary: 'Adds a user ({ name, password, xoauth2, displayName, oauth })',
|
|
200
212
|
handler: ({ body }) => {
|
|
201
|
-
control.addUser(body.name, { password: body.password, xoauth2: body.xoauth2 });
|
|
213
|
+
control.addUser(body.name, { password: body.password, xoauth2: body.xoauth2, displayName: body.displayName, oauth: body.oauth });
|
|
202
214
|
return { status: 201, body: { name: body.name } };
|
|
203
215
|
}
|
|
204
216
|
},
|
|
205
217
|
{
|
|
206
218
|
method: 'PUT',
|
|
207
219
|
path: '/v1/users/{name}',
|
|
208
|
-
summary: 'Changes the password
|
|
220
|
+
summary: 'Changes the password, the XOAUTH2 token, the display name (displayName) or the OAuth settings ({ oauth: { consent } }) of a user',
|
|
209
221
|
handler: ({ params, body }) => {
|
|
210
|
-
control.updateUser(params.name, { password: body.password, xoauth2: body.xoauth2 });
|
|
222
|
+
control.updateUser(params.name, { password: body.password, xoauth2: body.xoauth2, displayName: body.displayName, oauth: body.oauth });
|
|
211
223
|
return { name: params.name };
|
|
212
224
|
}
|
|
213
225
|
},
|
|
@@ -448,51 +460,67 @@ function matchRoute(routes, method, pathname) {
|
|
|
448
460
|
*/
|
|
449
461
|
function hasToken(header, token) {
|
|
450
462
|
const match = /^Bearer (.+)$/i.exec(header || '');
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
463
|
+
return !!match && safeEqual(match[1], token);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Compares two strings in constant time, also when their lengths differ
|
|
467
|
+
*
|
|
468
|
+
* @param {String} a First
|
|
469
|
+
* @param {String} b Second
|
|
470
|
+
* @return {Boolean} true if equal
|
|
471
|
+
*/
|
|
472
|
+
function safeEqual(a, b) {
|
|
454
473
|
const digest = (value) => node_crypto_1.default.createHash('sha256').update(value).digest();
|
|
455
|
-
return node_crypto_1.default.timingSafeEqual(digest(
|
|
474
|
+
return node_crypto_1.default.timingSafeEqual(digest(a), digest(b));
|
|
456
475
|
}
|
|
457
476
|
/**
|
|
458
|
-
* Reads a
|
|
477
|
+
* Reads a request body
|
|
459
478
|
*
|
|
460
479
|
* @param {Object} req HTTP request
|
|
461
|
-
* @
|
|
480
|
+
* @param {Number} limit Largest body in octets
|
|
481
|
+
* @param {Function} tooBig Makes the error for a larger body
|
|
482
|
+
* @return {Promise<Buffer>} the body
|
|
462
483
|
*/
|
|
463
|
-
function
|
|
484
|
+
function readRaw(req, limit, tooBig) {
|
|
464
485
|
return new Promise((resolve, reject) => {
|
|
465
486
|
const chunks = [];
|
|
466
487
|
let size = 0;
|
|
467
488
|
req.on('data', (chunk) => {
|
|
468
489
|
size += chunk.length;
|
|
469
|
-
if (size >
|
|
470
|
-
reject((
|
|
490
|
+
if (size > limit) {
|
|
491
|
+
reject(tooBig());
|
|
471
492
|
req.destroy();
|
|
472
493
|
return;
|
|
473
494
|
}
|
|
474
495
|
chunks.push(chunk);
|
|
475
496
|
});
|
|
476
497
|
req.on('error', reject);
|
|
477
|
-
req.on('end', () =>
|
|
478
|
-
const text = Buffer.concat(chunks).toString('utf-8');
|
|
479
|
-
if (!text) {
|
|
480
|
-
return resolve({});
|
|
481
|
-
}
|
|
482
|
-
let body;
|
|
483
|
-
try {
|
|
484
|
-
body = JSON.parse(text);
|
|
485
|
-
}
|
|
486
|
-
catch {
|
|
487
|
-
return reject((0, store_operations_js_1.storeError)('Request body is not valid JSON', 'INVALID'));
|
|
488
|
-
}
|
|
489
|
-
if (!body || typeof body !== 'object') {
|
|
490
|
-
return reject((0, store_operations_js_1.storeError)('Request body must be a JSON object or array', 'INVALID'));
|
|
491
|
-
}
|
|
492
|
-
resolve(body);
|
|
493
|
-
});
|
|
498
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
494
499
|
});
|
|
495
500
|
}
|
|
501
|
+
/**
|
|
502
|
+
* Reads a JSON request body
|
|
503
|
+
*
|
|
504
|
+
* @param {Object} req HTTP request
|
|
505
|
+
* @return {Promise<Object>} the body, an empty object without one
|
|
506
|
+
*/
|
|
507
|
+
async function readBody(req) {
|
|
508
|
+
const text = (await readRaw(req, BODY_LIMIT, () => (0, store_operations_js_1.storeError)('Request body is larger than 64 MiB', 'TOOBIG'))).toString('utf-8');
|
|
509
|
+
if (!text) {
|
|
510
|
+
return {};
|
|
511
|
+
}
|
|
512
|
+
let body;
|
|
513
|
+
try {
|
|
514
|
+
body = JSON.parse(text);
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
throw (0, store_operations_js_1.storeError)('Request body is not valid JSON', 'INVALID');
|
|
518
|
+
}
|
|
519
|
+
if (!body || typeof body !== 'object') {
|
|
520
|
+
throw (0, store_operations_js_1.storeError)('Request body must be a JSON object or array', 'INVALID');
|
|
521
|
+
}
|
|
522
|
+
return body;
|
|
523
|
+
}
|
|
496
524
|
/**
|
|
497
525
|
* Creates the HTTP server of the REST API, it does not listen yet
|
|
498
526
|
*
|
package/dist/cjs/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/cjs/server.js
CHANGED
|
@@ -58,6 +58,8 @@ const control_js_1 = require("./control.js");
|
|
|
58
58
|
const quirks_js_1 = require("./quirks.js");
|
|
59
59
|
const storage_schema_js_1 = require("./storage-schema.js");
|
|
60
60
|
const rest_js_1 = require("./rest.js");
|
|
61
|
+
const provider_js_1 = require("./oauth/provider.js");
|
|
62
|
+
const http_js_1 = require("./oauth/http.js");
|
|
61
63
|
const bundledCert = __importStar(require("./cert.js"));
|
|
62
64
|
const memory_socket_js_1 = require("./memory-socket.js");
|
|
63
65
|
const script_js_1 = require("./script.js");
|
|
@@ -234,6 +236,9 @@ class IMAPServer extends node_stream_1.Stream {
|
|
|
234
236
|
this.loadUsers();
|
|
235
237
|
// plugins register their control operations
|
|
236
238
|
this.control = new control_js_1.Control(this);
|
|
239
|
+
// registers its control operations too, so it exists only with the option
|
|
240
|
+
this.oauth = this.options.oauth ? new provider_js_1.OAuthProvider(this, this.options.oauth) : null;
|
|
241
|
+
this.oauthServer = null;
|
|
237
242
|
// a quirk preset can leave plugins out (no-move, no-uidplus)
|
|
238
243
|
(0, load_plugins_js_1.default)(this, this.options.plugins, quirks.removePlugins);
|
|
239
244
|
if (this.options.storage) {
|
|
@@ -242,6 +247,7 @@ class IMAPServer extends node_stream_1.Stream {
|
|
|
242
247
|
}
|
|
243
248
|
this.systemFlags = [].concat(this.options.systemFlags || ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen']);
|
|
244
249
|
this.loadStorage();
|
|
250
|
+
this.tlsServer = null;
|
|
245
251
|
this.smtpServer = null;
|
|
246
252
|
this.restServer = null;
|
|
247
253
|
}
|
|
@@ -262,6 +268,13 @@ class IMAPServer extends node_stream_1.Stream {
|
|
|
262
268
|
}
|
|
263
269
|
}
|
|
264
270
|
});
|
|
271
|
+
// a typo in an OAuth consent policy fails here
|
|
272
|
+
for (const name of Object.keys(this.users)) {
|
|
273
|
+
const user = this.users[name];
|
|
274
|
+
if (user && user.oauth !== undefined) {
|
|
275
|
+
user.oauth = (0, control_js_1.checkUserOAuth)(user.oauth, 'users[' + JSON.stringify(name) + '].oauth');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
265
278
|
}
|
|
266
279
|
/**
|
|
267
280
|
* Builds the mailboxes from the `storage` option, a deep copy of it. Message and mailbox handlers of plugins run
|
|
@@ -292,9 +305,26 @@ class IMAPServer extends node_stream_1.Stream {
|
|
|
292
305
|
*/
|
|
293
306
|
async start(port, host) {
|
|
294
307
|
const imapPort = await listenOn(this.server, port, host);
|
|
295
|
-
await Promise.all([
|
|
308
|
+
await Promise.all([
|
|
309
|
+
this.options.tls ? this.startTlsListener(host) : null,
|
|
310
|
+
this.options.smtp ? this.startSmtp() : null,
|
|
311
|
+
this.options.rest ? this.startRest() : null,
|
|
312
|
+
this.oauth ? this.startOAuth() : null
|
|
313
|
+
]);
|
|
296
314
|
return imapPort;
|
|
297
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* Starts the implicit TLS listener of the `tls` option. Its sessions are sessions of this server like the ones of
|
|
318
|
+
* the main listener, they share the storage and count as encrypted
|
|
319
|
+
*
|
|
320
|
+
* @param {String} [defaultHost] Address to listen on when the `tls` option has no host
|
|
321
|
+
* @return {Promise<Number>} resolves with the port of the TLS listener
|
|
322
|
+
*/
|
|
323
|
+
startTlsListener(defaultHost) {
|
|
324
|
+
const { port, host } = this.options.tls || {};
|
|
325
|
+
this.tlsServer = node_tls_1.default.createServer(this.getCredentials(), this.createClient.bind(this));
|
|
326
|
+
return listenOn(this.tlsServer, port, host || defaultHost);
|
|
327
|
+
}
|
|
298
328
|
/**
|
|
299
329
|
* Starts the REST API of the `rest` option, see src/rest.ts
|
|
300
330
|
*
|
|
@@ -305,6 +335,41 @@ class IMAPServer extends node_stream_1.Stream {
|
|
|
305
335
|
this.restServer = (0, rest_js_1.createRestServer)(this, { host, token });
|
|
306
336
|
return listenOn(this.restServer, port, host || '127.0.0.1');
|
|
307
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Starts the HTTP server of the OAuth provider (the `oauth` option, see src/oauth/http.ts). The issuer is its
|
|
340
|
+
* URL unless the `issuer` option sets another
|
|
341
|
+
*
|
|
342
|
+
* @return {Promise<Number>} resolves with the HTTP port
|
|
343
|
+
*/
|
|
344
|
+
async startOAuth() {
|
|
345
|
+
const oauth = this.oauth;
|
|
346
|
+
const { port, host = '127.0.0.1', secure } = oauth.options;
|
|
347
|
+
this.oauthServer = (0, http_js_1.createOAuthServer)(oauth, { secure, host, credentials: this.getCredentials() });
|
|
348
|
+
const listening = await listenOn(this.oauthServer, port, host);
|
|
349
|
+
oauth.listening(host, listening);
|
|
350
|
+
return listening;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Checks a bearer token of an IMAP or SMTP login: a token of the OAuth provider, or the static
|
|
354
|
+
* `xoauth2.accessToken` of a user, which never expires
|
|
355
|
+
*
|
|
356
|
+
* @param {String} token Access token
|
|
357
|
+
* @param {String} [username] The user the client logs in as, the owner of the token if not set
|
|
358
|
+
* @param {String} [via] "imap" or "smtp", for the events of the provider
|
|
359
|
+
* @return {Object} `{ user }`, or `{ error, reason }` with the OAuth error code (invalid_token, insufficient_scope)
|
|
360
|
+
*/
|
|
361
|
+
checkAccessToken(token, username, via = 'imap') {
|
|
362
|
+
const issued = this.oauth && this.oauth.verifyAccessToken(token, username, via);
|
|
363
|
+
if (issued) {
|
|
364
|
+
return issued;
|
|
365
|
+
}
|
|
366
|
+
const owns = (name) => {
|
|
367
|
+
const user = this.users[name];
|
|
368
|
+
return !!(user && user.xoauth2 && user.xoauth2.accessToken === token);
|
|
369
|
+
};
|
|
370
|
+
const user = username === undefined ? Object.keys(this.users).find(owns) : owns(username) ? username : undefined;
|
|
371
|
+
return user === undefined ? { error: 'invalid_token', reason: 'unknown access token' } : { user };
|
|
372
|
+
}
|
|
308
373
|
/**
|
|
309
374
|
* Starts the SMTP server of the `smtp` option. smtp-server is an optional dependency, loaded only here
|
|
310
375
|
*
|
|
@@ -344,7 +409,7 @@ class IMAPServer extends node_stream_1.Stream {
|
|
|
344
409
|
}
|
|
345
410
|
close(callback) {
|
|
346
411
|
this.closeListeners(true);
|
|
347
|
-
this.
|
|
412
|
+
this.closeImapListeners(callback);
|
|
348
413
|
// close() only completes once all connections are gone
|
|
349
414
|
this.connections.forEach((connection) => {
|
|
350
415
|
if (connection.socket) {
|
|
@@ -353,7 +418,25 @@ class IMAPServer extends node_stream_1.Stream {
|
|
|
353
418
|
});
|
|
354
419
|
}
|
|
355
420
|
/**
|
|
356
|
-
* Stops the
|
|
421
|
+
* Stops accepting IMAP connections, on the main listener and on the TLS listener of the `tls` option
|
|
422
|
+
*
|
|
423
|
+
* @param {Function} [callback] Called once both listeners are closed, which is after their last connection is
|
|
424
|
+
* gone, with the first error
|
|
425
|
+
*/
|
|
426
|
+
closeImapListeners(callback) {
|
|
427
|
+
const listeners = this.tlsServer ? [this.server, this.tlsServer] : [this.server];
|
|
428
|
+
this.tlsServer = null;
|
|
429
|
+
let pending = listeners.length;
|
|
430
|
+
let error;
|
|
431
|
+
listeners.forEach(listener => listener.close(err => {
|
|
432
|
+
error = error || err;
|
|
433
|
+
if (--pending === 0) {
|
|
434
|
+
callback?.(error);
|
|
435
|
+
}
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Stops the SMTP, REST and OAuth listeners of `start()`
|
|
357
440
|
*
|
|
358
441
|
* @param {Boolean} force true closes the open REST connections too, otherwise only the idle ones (a request that
|
|
359
442
|
* is answered, like POST /v1/shutdown, finishes first)
|
|
@@ -366,16 +449,13 @@ class IMAPServer extends node_stream_1.Stream {
|
|
|
366
449
|
if (this.restServer) {
|
|
367
450
|
// event streams never end on their own, a graceful shutdown would wait for them forever
|
|
368
451
|
this.restServer.endStreams?.();
|
|
369
|
-
this.restServer
|
|
370
|
-
// keep-alive connections would hold the server open
|
|
371
|
-
if (force) {
|
|
372
|
-
this.restServer.closeAllConnections?.();
|
|
373
|
-
}
|
|
374
|
-
else {
|
|
375
|
-
this.restServer.closeIdleConnections?.();
|
|
376
|
-
}
|
|
452
|
+
closeHttp(this.restServer, force);
|
|
377
453
|
this.restServer = null;
|
|
378
454
|
}
|
|
455
|
+
if (this.oauthServer) {
|
|
456
|
+
closeHttp(this.oauthServer, force);
|
|
457
|
+
this.oauthServer = null;
|
|
458
|
+
}
|
|
379
459
|
}
|
|
380
460
|
/**
|
|
381
461
|
* Returns TLS key and certificate. Without credentials in the options the
|
|
@@ -470,7 +550,8 @@ class IMAPServer extends node_stream_1.Stream {
|
|
|
470
550
|
* @return {Object|false} User data or false if there is no such user
|
|
471
551
|
*/
|
|
472
552
|
getUser(username) {
|
|
473
|
-
|
|
553
|
+
// users is a null-prototype object, but a plain object from the control API or a test may still be assigned
|
|
554
|
+
return (typeof username === 'string' && Object.hasOwn(this.users, username) && this.users[username]) || false;
|
|
474
555
|
}
|
|
475
556
|
/**
|
|
476
557
|
* Returns a mailbox object from folderCache
|
|
@@ -1152,6 +1233,8 @@ class IMAPServer extends node_stream_1.Stream {
|
|
|
1152
1233
|
break;
|
|
1153
1234
|
}
|
|
1154
1235
|
}
|
|
1236
|
+
// like expunge, before the sessions are told: APPEND, COPY, MOVE, REPLACE, SMTP and the control API all add here
|
|
1237
|
+
this.emit('append', mailbox, [message], this.activeConnection);
|
|
1155
1238
|
this.notify({
|
|
1156
1239
|
tag: '*',
|
|
1157
1240
|
attributes: [
|
|
@@ -1596,6 +1679,7 @@ class IMAPConnection {
|
|
|
1596
1679
|
* @param {Function} [callback] Called once the data was handed to the system (a transport: on the next turn)
|
|
1597
1680
|
*/
|
|
1598
1681
|
writeLayer(data, transport, callback) {
|
|
1682
|
+
this.trace('out', data);
|
|
1599
1683
|
if (transport) {
|
|
1600
1684
|
transport.write(data);
|
|
1601
1685
|
if (callback) {
|
|
@@ -1606,6 +1690,17 @@ class IMAPConnection {
|
|
|
1606
1690
|
this.writeRaw(data, callback);
|
|
1607
1691
|
}
|
|
1608
1692
|
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Emits the protocol traffic of the session as `trace` events, as the IMAP layer sees it: above TLS and COMPRESS
|
|
1695
|
+
*
|
|
1696
|
+
* @param {String} direction "in" for client input, "out" for server output
|
|
1697
|
+
* @param {Buffer} data The octets
|
|
1698
|
+
*/
|
|
1699
|
+
trace(direction, data) {
|
|
1700
|
+
if (this.server.listenerCount('trace')) {
|
|
1701
|
+
this.server.emit('trace', { session: this.sessionNumber, direction, data });
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1609
1704
|
/**
|
|
1610
1705
|
* Sends output through the script rule that handles its event, if there is one
|
|
1611
1706
|
*
|
|
@@ -1916,6 +2011,9 @@ class IMAPConnection {
|
|
|
1916
2011
|
if (this._resetting) {
|
|
1917
2012
|
return;
|
|
1918
2013
|
}
|
|
2014
|
+
if (chunk && chunk.length) {
|
|
2015
|
+
this.trace('in', chunk);
|
|
2016
|
+
}
|
|
1919
2017
|
// everything in one read arrived before anything this read causes to be sent
|
|
1920
2018
|
this._readCount = (this._readCount || 0) + 1;
|
|
1921
2019
|
str = (chunk || '').toString('binary');
|
|
@@ -3212,6 +3310,22 @@ const IS_DENO = 'Deno' in globalThis;
|
|
|
3212
3310
|
function nextTurn(fn) {
|
|
3213
3311
|
return IS_DENO ? setTimeout(fn, 0) : setImmediate(fn);
|
|
3214
3312
|
}
|
|
3313
|
+
/**
|
|
3314
|
+
* Stops an HTTP listener of `start()`
|
|
3315
|
+
*
|
|
3316
|
+
* @param {Object} server HTTP server
|
|
3317
|
+
* @param {Boolean} force true closes the open connections too, otherwise only the idle ones (keep-alive connections
|
|
3318
|
+
* would hold the server open)
|
|
3319
|
+
*/
|
|
3320
|
+
function closeHttp(server, force) {
|
|
3321
|
+
server.close();
|
|
3322
|
+
if (force) {
|
|
3323
|
+
server.closeAllConnections?.();
|
|
3324
|
+
}
|
|
3325
|
+
else {
|
|
3326
|
+
server.closeIdleConnections?.();
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3215
3329
|
/**
|
|
3216
3330
|
* Starts listening, resolves with the port or rejects with the listen error (EADDRINUSE)
|
|
3217
3331
|
*
|
|
@@ -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
|
|
@@ -4,8 +4,9 @@ exports.startSMTPServer = startSMTPServer;
|
|
|
4
4
|
const smtp_server_1 = require("smtp-server");
|
|
5
5
|
/**
|
|
6
6
|
* Starts an SMTP server that appends every received message to the INBOX
|
|
7
|
-
* of the given ImapKit IMAP server. Any sender, recipient and
|
|
8
|
-
*
|
|
7
|
+
* of the given ImapKit IMAP server. Any sender, recipient and password are
|
|
8
|
+
* accepted. With the OAuth provider, AUTH XOAUTH2 is offered too and checks
|
|
9
|
+
* the access token like the IMAP login does.
|
|
9
10
|
*
|
|
10
11
|
* @param {Number} smtpPort - port to listen on for SMTP commands
|
|
11
12
|
* @param {Object} imapServer - the ImapKit IMAP server
|
|
@@ -20,9 +21,20 @@ function startSMTPServer(smtpPort, imapServer, callback, host) {
|
|
|
20
21
|
key: credentials.key,
|
|
21
22
|
cert: credentials.cert,
|
|
22
23
|
authOptional: true,
|
|
24
|
+
// with the OAuth provider, clients can also log in with its access tokens
|
|
25
|
+
authMethods: imapServer.oauth ? ['PLAIN', 'LOGIN', 'XOAUTH2'] : ['PLAIN', 'LOGIN'],
|
|
23
26
|
logger: false,
|
|
24
27
|
onAuth(auth, session, done) {
|
|
25
|
-
|
|
28
|
+
// XOAUTH2 is only offered with the OAuth provider
|
|
29
|
+
if (auth.method !== 'XOAUTH2') {
|
|
30
|
+
return done(null, { user: auth.username });
|
|
31
|
+
}
|
|
32
|
+
const result = imapServer.checkAccessToken(auth.accessToken || '', auth.username, 'smtp');
|
|
33
|
+
if ('error' in result) {
|
|
34
|
+
// the JSON error of Gmail's XOAUTH2, the client answers it and gets 535
|
|
35
|
+
return done(null, { data: { status: '401', schemes: 'bearer', scope: imapServer.oauth.mailScope } });
|
|
36
|
+
}
|
|
37
|
+
done(null, { user: result.user });
|
|
26
38
|
},
|
|
27
39
|
onData(stream, session, done) {
|
|
28
40
|
const chunks = [];
|