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
@@ -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
- let username = request.authzid;
93
- if (username === false) {
94
- // without an authzid the token tells who the user is
95
- username = Object.keys(server.users).find(name => {
96
- const user = server.users[name];
97
- return user && user.xoauth2 && user.xoauth2.accessToken === token;
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({ status })).toString('base64')
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 (!userData.xoauth2 || userData.xoauth2.accessToken !== accessToken) {
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: 'https://mail.google.com/'
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
  ]
@@ -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: ' + EVENT_TYPES.join(', ') + ')',
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 unknown = types.filter(type => EVENT_TYPES.indexOf(type) < 0);
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 ' + EVENT_TYPES.join(', '), 'INVALID');
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 or the XOAUTH2 token of a user',
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
- if (!match) {
443
- return false;
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(match[1]), digest(token));
463
+ return crypto.timingSafeEqual(digest(a), digest(b));
447
464
  }
448
465
  /**
449
- * Reads a JSON request body
466
+ * Reads a request body
450
467
  *
451
468
  * @param {Object} req HTTP request
452
- * @return {Promise<Object>} the body, an empty object without one
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 readBody(req) {
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 > BODY_LIMIT) {
461
- reject(storeError('Request body is larger than 64 MiB', 'TOOBIG'));
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 };
@@ -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
  *
@@ -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([this.options.smtp ? this.startSmtp() : null, this.options.rest ? this.startRest() : null]);
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.server.close(callback);
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 SMTP and REST listeners of `start()`
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.close();
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
- return (typeof username === 'string' && this.users[username]) || false;
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 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
@@ -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 credentials
5
- * are accepted.
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
- done(null, { user: auth.username });
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 = [];