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.
Files changed (48) hide show
  1. package/README.md +41 -23
  2. package/bin/help.txt +29 -3
  3. package/bin/imapkit.js +43 -0
  4. package/dist/browser/imapkit.js +99 -35
  5. package/dist/cjs/control.d.ts +16 -5
  6. package/dist/cjs/control.js +36 -5
  7. package/dist/cjs/oauth/http.d.ts +28 -0
  8. package/dist/cjs/oauth/http.js +216 -0
  9. package/dist/cjs/oauth/jwt.d.ts +33 -0
  10. package/dist/cjs/oauth/jwt.js +58 -0
  11. package/dist/cjs/oauth/pages.d.ts +47 -0
  12. package/dist/cjs/oauth/pages.js +310 -0
  13. package/dist/cjs/oauth/policy.d.ts +48 -0
  14. package/dist/cjs/oauth/policy.js +66 -0
  15. package/dist/cjs/oauth/provider.d.ts +455 -0
  16. package/dist/cjs/oauth/provider.js +1422 -0
  17. package/dist/cjs/plugins/oauthbearer.js +10 -15
  18. package/dist/cjs/plugins/xoauth2.js +4 -3
  19. package/dist/cjs/rest.d.ts +18 -1
  20. package/dist/cjs/rest.js +63 -35
  21. package/dist/cjs/server.d.ts +49 -1
  22. package/dist/cjs/server.js +126 -12
  23. package/dist/cjs/smtp-listener.d.ts +3 -2
  24. package/dist/cjs/smtp-listener.js +15 -3
  25. package/dist/cjs/types.d.ts +16 -0
  26. package/dist/esm/control.d.ts +16 -5
  27. package/dist/esm/control.js +36 -6
  28. package/dist/esm/oauth/http.d.ts +28 -0
  29. package/dist/esm/oauth/http.js +211 -0
  30. package/dist/esm/oauth/jwt.d.ts +33 -0
  31. package/dist/esm/oauth/jwt.js +50 -0
  32. package/dist/esm/oauth/pages.d.ts +47 -0
  33. package/dist/esm/oauth/pages.js +305 -0
  34. package/dist/esm/oauth/policy.d.ts +48 -0
  35. package/dist/esm/oauth/policy.js +59 -0
  36. package/dist/esm/oauth/provider.d.ts +455 -0
  37. package/dist/esm/oauth/provider.js +1415 -0
  38. package/dist/esm/plugins/oauthbearer.js +10 -15
  39. package/dist/esm/plugins/xoauth2.js +4 -3
  40. package/dist/esm/rest.d.ts +18 -1
  41. package/dist/esm/rest.js +62 -36
  42. package/dist/esm/server.d.ts +49 -1
  43. package/dist/esm/server.js +127 -13
  44. package/dist/esm/smtp-listener.d.ts +3 -2
  45. package/dist/esm/smtp-listener.js +15 -3
  46. package/dist/esm/types.d.ts +16 -0
  47. package/dist/plugin-help.json +2 -2
  48. 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
- let username = request.authzid;
97
- if (username === false) {
98
- // without an authzid the token tells who the user is
99
- username = Object.keys(server.users).find(name => {
100
- const user = server.users[name];
101
- return user && user.xoauth2 && user.xoauth2.accessToken === token;
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({ status })).toString('base64')
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 (!userData.xoauth2 || userData.xoauth2.accessToken !== accessToken) {
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: 'https://mail.google.com/'
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
  ]
@@ -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: ' + EVENT_TYPES.join(', ') + ')',
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 unknown = types.filter(type => EVENT_TYPES.indexOf(type) < 0);
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 ' + EVENT_TYPES.join(', '), 'INVALID');
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 or the XOAUTH2 token of a user',
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
- if (!match) {
452
- return false;
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(match[1]), digest(token));
474
+ return node_crypto_1.default.timingSafeEqual(digest(a), digest(b));
456
475
  }
457
476
  /**
458
- * Reads a JSON request body
477
+ * Reads a request body
459
478
  *
460
479
  * @param {Object} req HTTP request
461
- * @return {Promise<Object>} the body, an empty object without one
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 readBody(req) {
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 > BODY_LIMIT) {
470
- reject((0, store_operations_js_1.storeError)('Request body is larger than 64 MiB', 'TOOBIG'));
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
  *
@@ -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 SMTP and REST listeners of `start()`
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
  *
@@ -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([this.options.smtp ? this.startSmtp() : null, this.options.rest ? this.startRest() : null]);
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.server.close(callback);
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 SMTP and REST listeners of `start()`
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.close();
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
- return (typeof username === 'string' && this.users[username]) || false;
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 credentials
6
- * are accepted.
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 credentials
8
- * are accepted.
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
- done(null, { user: auth.username });
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 = [];