emailengine-app 2.68.1 → 2.70.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 (95) hide show
  1. package/.github/workflows/deploy.yml +8 -3
  2. package/.github/workflows/release.yaml +6 -0
  3. package/CHANGELOG.md +59 -0
  4. package/Gruntfile.js +3 -1
  5. package/config/default.toml +2 -0
  6. package/data/google-crawlers.json +7 -1
  7. package/getswagger.sh +40 -4
  8. package/gettext-extract.js +163 -0
  9. package/lib/account.js +135 -72
  10. package/lib/api-routes/account-routes.js +684 -106
  11. package/lib/api-routes/blocklist-routes.js +344 -0
  12. package/lib/api-routes/chat-routes.js +32 -14
  13. package/lib/api-routes/delivery-test-routes.js +346 -0
  14. package/lib/api-routes/export-routes.js +28 -14
  15. package/lib/api-routes/gateway-routes.js +427 -0
  16. package/lib/api-routes/license-routes.js +156 -0
  17. package/lib/api-routes/mailbox-routes.js +344 -0
  18. package/lib/api-routes/message-routes.js +221 -187
  19. package/lib/api-routes/oauth2-app-routes.js +697 -0
  20. package/lib/api-routes/outbox-routes.js +185 -0
  21. package/lib/api-routes/pubsub-routes.js +102 -0
  22. package/lib/api-routes/route-helpers.js +58 -0
  23. package/lib/api-routes/settings-routes.js +357 -0
  24. package/lib/api-routes/stats-routes.js +111 -0
  25. package/lib/api-routes/submit-routes.js +461 -0
  26. package/lib/api-routes/template-routes.js +60 -75
  27. package/lib/api-routes/token-routes.js +297 -0
  28. package/lib/api-routes/webhook-route-routes.js +181 -0
  29. package/lib/autodetect-imap-settings.js +0 -2
  30. package/lib/consts.js +5 -0
  31. package/lib/email-client/base-client.js +28 -6
  32. package/lib/email-client/gmail-client.js +133 -112
  33. package/lib/email-client/imap/mailbox.js +34 -11
  34. package/lib/email-client/imap/subconnection.js +20 -13
  35. package/lib/email-client/imap/sync-operations.js +131 -3
  36. package/lib/email-client/imap-client.js +152 -75
  37. package/lib/email-client/notification-handler.js +1 -4
  38. package/lib/email-client/outlook-client.js +134 -75
  39. package/lib/export.js +97 -20
  40. package/lib/feature-flags.js +2 -2
  41. package/lib/gateway.js +4 -9
  42. package/lib/get-raw-email.js +5 -5
  43. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  44. package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
  45. package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -24
  46. package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
  47. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  48. package/lib/logger.js +24 -21
  49. package/lib/message-port-stream.js +113 -16
  50. package/lib/metrics-collector.js +0 -2
  51. package/lib/oauth2-apps.js +13 -4
  52. package/lib/outbox.js +24 -40
  53. package/lib/redis-operations.js +1 -1
  54. package/lib/reject-worker-calls.js +42 -0
  55. package/lib/routes-ui.js +37 -8778
  56. package/lib/schemas.js +429 -84
  57. package/lib/sentry.js +139 -0
  58. package/lib/settings.js +9 -3
  59. package/lib/stream-encrypt.js +1 -1
  60. package/lib/templates.js +1 -1
  61. package/lib/tokens.js +5 -3
  62. package/lib/tools.js +70 -4
  63. package/lib/ui-routes/account-routes.js +45 -212
  64. package/lib/ui-routes/admin-config-routes.js +928 -489
  65. package/lib/ui-routes/admin-entities-routes.js +1 -0
  66. package/lib/ui-routes/auth-routes.js +1339 -0
  67. package/lib/ui-routes/dashboard-routes.js +188 -0
  68. package/lib/ui-routes/document-store-routes.js +800 -0
  69. package/lib/ui-routes/export-routes.js +217 -0
  70. package/lib/ui-routes/internals-routes.js +354 -0
  71. package/lib/ui-routes/network-config-routes.js +759 -0
  72. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +369 -91
  73. package/lib/ui-routes/route-helpers.js +314 -0
  74. package/lib/ui-routes/smtp-test-routes.js +236 -0
  75. package/lib/ui-routes/unsubscribe-routes.js +232 -0
  76. package/lib/webhook-request.js +36 -0
  77. package/lib/webhooks.js +8 -4
  78. package/package.json +13 -12
  79. package/sbom.json +1 -1
  80. package/server.js +222 -39
  81. package/static/licenses.html +160 -300
  82. package/translations/messages.pot +112 -132
  83. package/update-info.sh +19 -1
  84. package/views/config/logging.hbs +48 -0
  85. package/views/dashboard.hbs +7 -26
  86. package/views/internals/index.hbs +15 -0
  87. package/views/tokens/index.hbs +9 -0
  88. package/workers/api.js +200 -4424
  89. package/workers/documents.js +2 -22
  90. package/workers/export.js +103 -104
  91. package/workers/imap-proxy.js +3 -23
  92. package/workers/imap.js +32 -36
  93. package/workers/smtp.js +2 -22
  94. package/workers/submit.js +26 -35
  95. package/workers/webhooks.js +9 -43
@@ -4,10 +4,13 @@ const crypto = require('crypto');
4
4
  const { redis } = require('../db');
5
5
  const { Account } = require('../account');
6
6
  const getSecret = require('../get-secret');
7
- const { oauth2Apps, SERVICE_ACCOUNT_PROVIDERS } = require('../oauth2-apps');
7
+ const { oauth2Apps, LEGACY_KEYS, SERVICE_ACCOUNT_PROVIDERS } = require('../oauth2-apps');
8
+ const settings = require('../settings');
9
+ const { autodetectImapSettings } = require('../autodetect-imap-settings');
8
10
  const Boom = require('@hapi/boom');
9
11
  const Joi = require('joi');
10
- const { failAction } = require('../tools');
12
+ const { failAction, getSignedFormData, getLogs, verifyAccountInfo } = require('../tools');
13
+ const { handleError } = require('./route-helpers');
11
14
 
12
15
  const {
13
16
  settingsSchema,
@@ -21,11 +24,44 @@ const {
21
24
  smtpSchema,
22
25
  smtpUpdateSchema,
23
26
  oauth2Schema,
24
- oauth2UpdateSchema
27
+ oauth2UpdateSchema,
28
+ defaultAccountTypeSchema,
29
+ shortMailboxesSchema,
30
+ ACCOUNT_DISPLAY_STATES,
31
+ errorResponses
25
32
  } = require('../schemas');
26
33
 
27
34
  const { REDIS_PREFIX, MAX_FORM_TTL, NONCE_BYTES } = require('../consts');
28
35
 
36
+ // OAuth2 scope list stored on the account (accountData.oauth2.scope). Reused with a per-route
37
+ // description/label where the same value is exposed under a different field name
38
+ const accountOauth2ScopesSchema = Joi.array()
39
+ .items(Joi.string().example('https://mail.google.com/').label('AccountScopeEntry'))
40
+ .description('OAuth2 scopes granted for this account')
41
+ .label('AccountOauth2Scopes');
42
+
43
+ // Per-protocol result block for the account verification response - the IMAP and SMTP results
44
+ // carry the same fields built by verifyAccountInfo, only labels, wording, and the server
45
+ // response example differ
46
+ const verifyResultSchema = (proto, responseTextExample) =>
47
+ Joi.object({
48
+ success: Joi.boolean().example(true).description(`Was ${proto.toUpperCase()} account verified`).label(`Verify${proto}Success`),
49
+ error: Joi.string()
50
+ .example('Something went wrong')
51
+ .description(`Error messages for ${proto.toUpperCase()} verification. Only present if success=false`)
52
+ .label(`Verify${proto}Error`),
53
+ code: Joi.string().example('ERR_SSL_WRONG_VERSION_NUMBER').description('Error code. Only present if success=false').label(`Verify${proto}Code`),
54
+ statusCode: Joi.number()
55
+ .integer()
56
+ .example(500)
57
+ .description('HTTP-style status code for the error. Only present if success=false')
58
+ .label(`Verify${proto}StatusCode`),
59
+ responseText: Joi.string()
60
+ .example(responseTextExample)
61
+ .description('Server response for the failed verification. Only present if success=false')
62
+ .label(`Verify${proto}ResponseText`)
63
+ }).label(`Verify${proto}Result`);
64
+
29
65
  /**
30
66
  * Validates that delegation fields are only used with OAuth2 accounts.
31
67
  */
@@ -46,7 +82,9 @@ async function init(args) {
46
82
  imapSchema: imapSchemaArg,
47
83
  smtpSchema: smtpSchemaArg,
48
84
  CORS_CONFIG,
49
- AccountTypeSchema
85
+ AccountTypeSchema,
86
+ OAuth2ProviderSchema,
87
+ metrics
50
88
  } = args;
51
89
 
52
90
  // POST /v1/account - Create account
@@ -133,15 +171,7 @@ async function init(args) {
133
171
  let result = await accountObject.create(request.payload);
134
172
  return result;
135
173
  } catch (err) {
136
- request.logger.error({ msg: 'API request failed', err });
137
- if (Boom.isBoom(err)) {
138
- throw err;
139
- }
140
- let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
141
- if (err.code) {
142
- error.output.payload.code = err.code;
143
- }
144
- throw error;
174
+ handleError(request, err);
145
175
  }
146
176
  },
147
177
 
@@ -150,7 +180,11 @@ async function init(args) {
150
180
  notes: 'Registers new IMAP account to be synced',
151
181
  tags: ['api', 'Account'],
152
182
 
153
- plugins: {},
183
+ plugins: {
184
+ 'hapi-swagger': {
185
+ responses: errorResponses(400, 401, 403, 404, 429, 500)
186
+ }
187
+ },
154
188
 
155
189
  auth: {
156
190
  strategy: 'api-token',
@@ -248,13 +282,19 @@ async function init(args) {
248
282
 
249
283
  response: {
250
284
  schema: Joi.object({
251
- account: accountIdSchema.required(),
285
+ account: accountIdSchema,
252
286
  state: Joi.string()
253
- .required()
254
287
  .valid('existing', 'new')
255
288
  .example('new')
256
- .description('Is the account new or updated existing')
257
- .label('CreateAccountState')
289
+ .description('Is the account new or updated existing. Not present when a redirect URL is returned')
290
+ .label('CreateAccountState'),
291
+ redirect: Joi.string()
292
+ .uri()
293
+ .example('https://emailengine.example.com/oauth?account=example')
294
+ .description(
295
+ 'OAuth2 authorization URL. Returned instead of account and state when the request used oauth2.authorize=true - send the user to this URL to complete the OAuth2 flow'
296
+ )
297
+ .label('CreateAccountRedirect')
258
298
  }).label('CreateAccountResponse'),
259
299
  failAction: 'log'
260
300
  }
@@ -281,15 +321,7 @@ async function init(args) {
281
321
 
282
322
  return await accountObject.update(request.payload);
283
323
  } catch (err) {
284
- request.logger.error({ msg: 'API request failed', err });
285
- if (Boom.isBoom(err)) {
286
- throw err;
287
- }
288
- let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
289
- if (err.code) {
290
- error.output.payload.code = err.code;
291
- }
292
- throw error;
324
+ handleError(request, err);
293
325
  }
294
326
  },
295
327
  options: {
@@ -297,7 +329,11 @@ async function init(args) {
297
329
  notes: 'Updates account information',
298
330
  tags: ['api', 'Account'],
299
331
 
300
- plugins: {},
332
+ plugins: {
333
+ 'hapi-swagger': {
334
+ responses: errorResponses(400, 401, 403, 404, 429, 500)
335
+ }
336
+ },
301
337
 
302
338
  auth: {
303
339
  strategy: 'api-token',
@@ -397,15 +433,7 @@ async function init(args) {
397
433
  try {
398
434
  return { reconnect: await accountObject.requestReconnect(request.payload) };
399
435
  } catch (err) {
400
- request.logger.error({ msg: 'API request failed', err });
401
- if (Boom.isBoom(err)) {
402
- throw err;
403
- }
404
- let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
405
- if (err.code) {
406
- error.output.payload.code = err.code;
407
- }
408
- throw error;
436
+ handleError(request, err);
409
437
  }
410
438
  },
411
439
  options: {
@@ -413,7 +441,11 @@ async function init(args) {
413
441
  notes: 'Requests connection to be reconnected',
414
442
  tags: ['api', 'Account'],
415
443
 
416
- plugins: {},
444
+ plugins: {
445
+ 'hapi-swagger': {
446
+ responses: errorResponses(400, 401, 403, 404, 429, 500)
447
+ }
448
+ },
417
449
 
418
450
  auth: {
419
451
  strategy: 'api-token',
@@ -464,15 +496,7 @@ async function init(args) {
464
496
  try {
465
497
  return { sync: await accountObject.requestSync(request.payload) };
466
498
  } catch (err) {
467
- request.logger.error({ msg: 'API request failed', err });
468
- if (Boom.isBoom(err)) {
469
- throw err;
470
- }
471
- let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
472
- if (err.code) {
473
- error.output.payload.code = err.code;
474
- }
475
- throw error;
499
+ handleError(request, err);
476
500
  }
477
501
  },
478
502
  options: {
@@ -480,7 +504,11 @@ async function init(args) {
480
504
  notes: 'Immediately trigger account syncing for IMAP accounts',
481
505
  tags: ['api', 'Account'],
482
506
 
483
- plugins: {},
507
+ plugins: {
508
+ 'hapi-swagger': {
509
+ responses: errorResponses(400, 401, 403, 404, 429, 500)
510
+ }
511
+ },
484
512
 
485
513
  auth: {
486
514
  strategy: 'api-token',
@@ -532,15 +560,7 @@ async function init(args) {
532
560
  try {
533
561
  return await accountObject.delete({ revoke: request.query.revoke });
534
562
  } catch (err) {
535
- request.logger.error({ msg: 'API request failed', err });
536
- if (Boom.isBoom(err)) {
537
- throw err;
538
- }
539
- let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
540
- if (err.code) {
541
- error.output.payload.code = err.code;
542
- }
543
- throw error;
563
+ handleError(request, err);
544
564
  }
545
565
  },
546
566
  options: {
@@ -549,7 +569,11 @@ async function init(args) {
549
569
 
550
570
  tags: ['api', 'Account'],
551
571
 
552
- plugins: {},
572
+ plugins: {
573
+ 'hapi-swagger': {
574
+ responses: errorResponses(400, 401, 403, 404, 429, 500)
575
+ }
576
+ },
553
577
 
554
578
  auth: {
555
579
  strategy: 'api-token',
@@ -608,15 +632,7 @@ async function init(args) {
608
632
  try {
609
633
  return { flush: await accountObject.flush(request.payload) };
610
634
  } catch (err) {
611
- request.logger.error({ msg: 'API request failed', err });
612
- if (Boom.isBoom(err)) {
613
- throw err;
614
- }
615
- let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
616
- if (err.code) {
617
- error.output.payload.code = err.code;
618
- }
619
- throw error;
635
+ handleError(request, err);
620
636
  }
621
637
  },
622
638
  options: {
@@ -624,7 +640,11 @@ async function init(args) {
624
640
  notes: 'Deletes all email indexes from Redis and ElasticSearch and re-creates the index for that account. You can only run a single flush operation at a time, so you must wait until the previous flush has finished before initiating a new one.',
625
641
  tags: ['api', 'Account'],
626
642
 
627
- plugins: {},
643
+ plugins: {
644
+ 'hapi-swagger': {
645
+ responses: errorResponses(400, 401, 403, 404, 429, 500)
646
+ }
647
+ },
628
648
 
629
649
  auth: {
630
650
  strategy: 'api-token',
@@ -678,15 +698,7 @@ async function init(args) {
678
698
 
679
699
  return await accountObject.listAccounts(request.query.state, request.query.query, request.query.page, request.query.pageSize);
680
700
  } catch (err) {
681
- request.logger.error({ msg: 'API request failed', err });
682
- if (Boom.isBoom(err)) {
683
- throw err;
684
- }
685
- let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
686
- if (err.code) {
687
- error.output.payload.code = err.code;
688
- }
689
- throw error;
701
+ handleError(request, err);
690
702
  }
691
703
  },
692
704
 
@@ -695,7 +707,11 @@ async function init(args) {
695
707
  notes: 'Lists registered accounts',
696
708
  tags: ['api', 'Account'],
697
709
 
698
- plugins: {},
710
+ plugins: {
711
+ 'hapi-swagger': {
712
+ responses: errorResponses(400, 401, 403, 429, 500)
713
+ }
714
+ },
699
715
 
700
716
  auth: {
701
717
  strategy: 'api-token',
@@ -722,7 +738,7 @@ async function init(args) {
722
738
  .label('PageNumber'),
723
739
  pageSize: Joi.number().integer().min(1).max(1000).default(20).example(20).description('How many entries per page').label('PageSize'),
724
740
  state: Joi.string()
725
- .valid('init', 'syncing', 'connecting', 'connected', 'authenticationError', 'connectError', 'unset', 'disconnected')
741
+ .valid(...ACCOUNT_DISPLAY_STATES)
726
742
  .example('connected')
727
743
  .description('Filter accounts by state')
728
744
  .label('AccountState'),
@@ -736,17 +752,27 @@ async function init(args) {
736
752
  page: Joi.number().integer().example(0).description('Current page (0-based index)').label('PageNumber'),
737
753
  pages: Joi.number().integer().example(24).description('Total page count').label('PagesNumber'),
738
754
 
755
+ query: Joi.string()
756
+ .allow(false)
757
+ .example(false)
758
+ .description('Search term used for filtering, or false when no filter was applied')
759
+ .label('AccountsQueryFilter'),
760
+ state: Joi.string()
761
+ .example('*')
762
+ .description('Account state filter used for the listing, or "*" when no filter was applied')
763
+ .label('AccountsStateFilter'),
764
+
739
765
  accounts: Joi.array()
740
766
  .items(
741
767
  Joi.object({
742
768
  account: accountIdSchema.required(),
743
- name: Joi.string().max(256).example('My Email Account').description('Display name for the account'),
769
+ name: Joi.string().allow('').max(256).example('My Email Account').description('Display name for the account'),
744
770
  email: Joi.string().empty('').email().example('user@example.com').description('Default email address of the account'),
745
771
  type: AccountTypeSchema,
746
772
  app: Joi.string().max(256).example('AAABhaBPHscAAAAH').description('OAuth2 application ID'),
747
773
  state: Joi.string()
748
774
  .required()
749
- .valid('init', 'syncing', 'connecting', 'connected', 'authenticationError', 'connectError', 'unset', 'disconnected')
775
+ .valid(...ACCOUNT_DISPLAY_STATES)
750
776
  .example('connected')
751
777
  .description('Account state')
752
778
  .label('AccountListState'),
@@ -762,8 +788,11 @@ async function init(args) {
762
788
 
763
789
  counters: accountCountersSchema,
764
790
 
765
- syncTime: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('Last sync time'),
766
- lastError: lastErrorSchema.allow(null)
791
+ syncTime: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('Last sync time (IMAP accounts only)'),
792
+ lastError: lastErrorSchema.allow(null),
793
+ delegationError: Joi.string()
794
+ .example('Delegated account was not found')
795
+ .description('Error message if the delegated account could not be resolved')
767
796
  }).label('AccountResponseItem')
768
797
  )
769
798
  .label('AccountEntries')
@@ -866,16 +895,15 @@ async function init(args) {
866
895
  oauth2App = await oauth2Apps.get(accountData.oauth2.provider);
867
896
 
868
897
  if (oauth2App) {
898
+ result.type = oauth2App.provider;
869
899
  // Check if account is already marked as send-only
870
900
  if (accountData.sendOnly) {
871
901
  result.sendOnly = true;
872
- } else {
873
- result.type = oauth2App.provider;
874
902
  }
875
903
  if (oauth2App.id !== oauth2App.provider) {
876
904
  result.app = oauth2App.id;
877
905
  }
878
- result.baseScopes = oauth2App.baseScope || 'imap';
906
+ result.baseScopes = oauth2App.baseScopes || 'imap';
879
907
  } else {
880
908
  result.type = 'oauth2';
881
909
  }
@@ -910,15 +938,7 @@ async function init(args) {
910
938
 
911
939
  return result;
912
940
  } catch (err) {
913
- request.logger.error({ msg: 'API request failed', err });
914
- if (Boom.isBoom(err)) {
915
- throw err;
916
- }
917
- let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
918
- if (err.code) {
919
- error.output.payload.code = err.code;
920
- }
921
- throw error;
941
+ handleError(request, err);
922
942
  }
923
943
  },
924
944
  options: {
@@ -926,6 +946,12 @@ async function init(args) {
926
946
  notes: 'Returns stored information about the account. Passwords are not included.',
927
947
  tags: ['api', 'Account'],
928
948
 
949
+ plugins: {
950
+ 'hapi-swagger': {
951
+ responses: errorResponses(400, 401, 403, 404, 429, 500)
952
+ }
953
+ },
954
+
929
955
  auth: {
930
956
  strategy: 'api-token',
931
957
  mode: 'required'
@@ -958,7 +984,7 @@ async function init(args) {
958
984
  schema: Joi.object({
959
985
  account: accountIdSchema.required(),
960
986
 
961
- name: Joi.string().max(256).example('My Email Account').description('Display name for the account'),
987
+ name: Joi.string().allow('').max(256).example('My Email Account').description('Display name for the account'),
962
988
  email: Joi.string().empty('').email().example('user@example.com').description('Default email address of the account'),
963
989
 
964
990
  copy: Joi.boolean().example(true).description('Copy submitted messages to Sent folder'),
@@ -974,6 +1000,7 @@ async function init(args) {
974
1000
  subconnections: accountSchemas.subconnections,
975
1001
 
976
1002
  webhooks: Joi.string()
1003
+ .allow('')
977
1004
  .uri({
978
1005
  scheme: ['http', 'https'],
979
1006
  allowRelative: false
@@ -987,10 +1014,22 @@ async function init(args) {
987
1014
 
988
1015
  smtp: Joi.object(smtpSchema).description('SMTP configuration').label('SMTPResponse'),
989
1016
 
990
- oauth2: Joi.object(oauth2Schema).description('OAuth2 configuration').label('Oauth2Response'),
1017
+ oauth2: Joi.object(oauth2Schema)
1018
+ .keys({
1019
+ scope: accountOauth2ScopesSchema,
1020
+ tokenType: Joi.string().example('Bearer').description('OAuth2 token type'),
1021
+ generated: Joi.date().iso().example('2021-03-22T13:13:31.000Z').description('When the current access token was generated'),
1022
+ refreshTokenGenerated: Joi.date()
1023
+ .iso()
1024
+ .example('2021-03-22T13:13:31.000Z')
1025
+ .description('When the current refresh token was generated'),
1026
+ userFlag: Joi.object().unknown().description('Account-level OAuth2 error flag, if any').label('AccountOauth2UserFlag')
1027
+ })
1028
+ .description('OAuth2 configuration')
1029
+ .label('Oauth2Response'),
991
1030
 
992
1031
  state: Joi.string()
993
- .valid('init', 'syncing', 'connecting', 'connected', 'authenticationError', 'connectError', 'unset', 'disconnected')
1032
+ .valid(...ACCOUNT_DISPLAY_STATES)
994
1033
  .example('connected')
995
1034
  .description('Informational account state')
996
1035
  .label('AccountInfoState'),
@@ -1006,11 +1045,30 @@ async function init(args) {
1006
1045
  description: Joi.string().example('Authentication failed').description('Error information'),
1007
1046
  responseCode: Joi.number().integer().example(500).description('Error status code'),
1008
1047
  code: Joi.string().example('EAUTH').description('Error type identifier'),
1009
- command: Joi.string().example('AUTH PLAIN').description('SMTP command that failed')
1048
+ command: Joi.string().example('AUTH PLAIN').description('SMTP command that failed'),
1049
+ networkRouting: Joi.object()
1050
+ .unknown()
1051
+ .allow(null)
1052
+ .description('Network routing information for the delivery attempt')
1053
+ .label('SMTPStatusNetworkRouting')
1010
1054
  })
1011
- .description('Information about the last SMTP connection attempt')
1055
+ .allow(null)
1056
+ .description('Information about the last SMTP connection attempt. Null when no SMTP connection has been attempted')
1012
1057
  .label('SMTPInfoStatus'),
1013
1058
 
1059
+ syncError: Joi.object({
1060
+ path: Joi.string().example('INBOX').description('Mailbox folder path where the error occurred'),
1061
+ time: Joi.date().iso().example('2021-07-08T07:06:34.336Z').description('When the error occurred'),
1062
+ error: Joi.string().example('Failed to open mailbox').description('Error message')
1063
+ })
1064
+ .unknown()
1065
+ .description('Information about the last mailbox sync error (IMAP accounts only)')
1066
+ .label('AccountSyncError'),
1067
+
1068
+ connections: Joi.number().integer().example(2).description('Number of open IMAP connections for this account (IMAP accounts only)'),
1069
+
1070
+ sendOnly: Joi.boolean().example(false).description('Whether this is a send-only account that does not sync messages'),
1071
+
1014
1072
  webhooksCustomHeaders: settingsSchema.webhooksCustomHeaders.label('AccountWebhooksCustomHeaders'),
1015
1073
 
1016
1074
  locale: Joi.string().empty('').max(100).example('fr').description('Optional locale'),
@@ -1039,17 +1097,23 @@ async function init(args) {
1039
1097
  'Account quota information if query argument quota=true. This value will be false if the server does not provide quota information.'
1040
1098
  ),
1041
1099
 
1042
- syncTime: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('Last sync time'),
1100
+ syncTime: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('Last sync time (IMAP accounts only)'),
1043
1101
 
1044
1102
  outlookSubscription: Joi.object({
1045
1103
  id: Joi.string().description('Microsoft Graph subscription ID'),
1046
- expirationDateTime: Joi.date().iso().description('When the subscription expires'),
1104
+ expirationDateTime: Joi.date().iso().allow(null).description('When the subscription expires'),
1047
1105
  state: Joi.object({
1048
- state: Joi.string().valid('creating', 'created', 'error').description('Subscription state'),
1106
+ state: Joi.string().valid('creating', 'created', 'renewing', 'error').description('Subscription state'),
1049
1107
  time: Joi.number().description('Timestamp of last state change'),
1050
- error: Joi.string().description('Error message if state is error')
1051
- }).description('Current subscription state')
1052
- }).description('Microsoft Graph subscription details (Outlook accounts only)'),
1108
+ error: Joi.string().allow(null).description('Error message if state is error, null after a successful renewal'),
1109
+ retryCount: Joi.number().integer().description('How many times the subscription renewal has been retried'),
1110
+ createRetryCount: Joi.number().integer().description('How many times the subscription creation has been retried')
1111
+ })
1112
+ .unknown()
1113
+ .description('Current subscription state')
1114
+ })
1115
+ .unknown()
1116
+ .description('Microsoft Graph subscription details (Outlook accounts only)'),
1053
1117
 
1054
1118
  lastError: lastErrorSchema.allow(null)
1055
1119
  }).label('AccountResponse'),
@@ -1057,6 +1121,520 @@ async function init(args) {
1057
1121
  }
1058
1122
  }
1059
1123
  });
1124
+
1125
+ server.route({
1126
+ method: 'GET',
1127
+ path: '/v1/account/{account}/oauth-token',
1128
+
1129
+ async handler(request) {
1130
+ let enableOAuthTokensApi = await settings.get('enableOAuthTokensApi');
1131
+ if (!enableOAuthTokensApi) {
1132
+ let error = Boom.boomify(new Error('Disabled API endpoint'), { statusCode: 403 });
1133
+ error.output.payload.code = 'ApiEndpointDisabled';
1134
+ throw error;
1135
+ }
1136
+
1137
+ let accountObject = new Account({
1138
+ redis,
1139
+ account: request.params.account,
1140
+ call,
1141
+ secret: await getSecret(),
1142
+ timeout: request.headers['x-ee-timeout']
1143
+ });
1144
+
1145
+ try {
1146
+ const tokenData = await accountObject.getActiveAccessTokenData();
1147
+
1148
+ // Account data stores the OAuth2 app ID as the provider value. Resolve it to the
1149
+ // provider name and expose the app ID separately. Legacy app IDs already equal
1150
+ // their provider name, so skip the lookup for these.
1151
+ if (tokenData.provider && !LEGACY_KEYS.includes(tokenData.provider)) {
1152
+ let oauth2App = await oauth2Apps.get(tokenData.provider);
1153
+ if (oauth2App) {
1154
+ if (oauth2App.id !== oauth2App.provider) {
1155
+ tokenData.app = oauth2App.id;
1156
+ }
1157
+ tokenData.provider = oauth2App.provider;
1158
+ }
1159
+ }
1160
+
1161
+ // Record metric if token was actually refreshed (not cached)
1162
+ if (!tokenData.cached) {
1163
+ const provider = tokenData.provider || 'unknown';
1164
+ metrics(request.logger, 'oauth2TokenRefresh', 'inc', { status: 'success', provider, statusCode: '200' });
1165
+ }
1166
+
1167
+ return tokenData;
1168
+ } catch (err) {
1169
+ // Record failed token refresh
1170
+ const statusCode = String(err.statusCode || 0);
1171
+ metrics(request.logger, 'oauth2TokenRefresh', 'inc', { status: 'failure', provider: 'unknown', statusCode });
1172
+
1173
+ handleError(request, err);
1174
+ }
1175
+ },
1176
+
1177
+ options: {
1178
+ description: 'Get OAuth2 access token',
1179
+ notes: 'Get the active OAuth2 access token for an account. NB! This endpoint is disabled by default and needs activation on the Service configuration page.',
1180
+ tags: ['api', 'Account'],
1181
+
1182
+ plugins: {
1183
+ 'hapi-swagger': {
1184
+ responses: errorResponses(400, 401, 403, 404, 429, 500)
1185
+ }
1186
+ },
1187
+
1188
+ auth: {
1189
+ strategy: 'api-token',
1190
+ mode: 'required'
1191
+ },
1192
+ cors: CORS_CONFIG,
1193
+
1194
+ validate: {
1195
+ options: {
1196
+ stripUnknown: false,
1197
+ abortEarly: false,
1198
+ convert: true
1199
+ },
1200
+ failAction,
1201
+ params: Joi.object({
1202
+ account: accountIdSchema.required()
1203
+ })
1204
+ },
1205
+
1206
+ response: {
1207
+ schema: Joi.object({
1208
+ account: accountIdSchema.required(),
1209
+ user: Joi.string().max(256).required().example('user@example.com').description('Username'),
1210
+ accessToken: Joi.string()
1211
+ .max(4 * 4096)
1212
+ .example('aGVsbG8gd29ybGQ=')
1213
+ .description('Access Token. Can be missing if the external authentication server provided password-based credentials')
1214
+ .label('OAuthAccessToken'),
1215
+ provider: OAuth2ProviderSchema,
1216
+ app: Joi.string().max(256).example('AAABhaBPHscAAAAH').description('OAuth2 application ID'),
1217
+ registeredScopes: accountOauth2ScopesSchema.description('OAuth2 scopes registered for this account').label('RegisteredScopes'),
1218
+ expires: Joi.date().iso().example('2021-03-22T13:13:31.000Z').description('When the access token expires'),
1219
+ cached: Joi.boolean().example(false).description('Whether the token was returned from cache or was freshly renewed')
1220
+ }).label('AccountTokenResponse'),
1221
+ failAction: 'log'
1222
+ }
1223
+ }
1224
+ });
1225
+
1226
+ server.route({
1227
+ method: 'GET',
1228
+ path: '/v1/account/{account}/server-signatures',
1229
+
1230
+ async handler(request) {
1231
+ let accountObject = new Account({
1232
+ redis,
1233
+ account: request.params.account,
1234
+ call,
1235
+ secret: await getSecret(),
1236
+ timeout: request.headers['x-ee-timeout']
1237
+ });
1238
+ try {
1239
+ return await accountObject.listSignatures(request.query);
1240
+ } catch (err) {
1241
+ handleError(request, err);
1242
+ }
1243
+ },
1244
+
1245
+ options: {
1246
+ description: 'List Account Signatures',
1247
+ notes: 'Returns signatures associated with the account. Currently only Gmail is supported, and only "new message" signatures from the "sendAs" list are returned.',
1248
+ tags: ['api', 'Account'],
1249
+
1250
+ plugins: {
1251
+ 'hapi-swagger': {
1252
+ responses: errorResponses(400, 401, 403, 404, 429, 500)
1253
+ }
1254
+ },
1255
+
1256
+ auth: {
1257
+ strategy: 'api-token',
1258
+ mode: 'required'
1259
+ },
1260
+ cors: CORS_CONFIG,
1261
+
1262
+ validate: {
1263
+ options: {
1264
+ stripUnknown: false,
1265
+ abortEarly: false,
1266
+ convert: true
1267
+ },
1268
+ failAction,
1269
+ params: Joi.object({
1270
+ account: accountIdSchema.required()
1271
+ })
1272
+ },
1273
+
1274
+ response: {
1275
+ schema: Joi.object({
1276
+ signatures: Joi.array()
1277
+ .items(
1278
+ Joi.object({
1279
+ address: Joi.string().email().example('user@example.com').description('Email address associated with the signature').required(),
1280
+ signature: Joi.string().example('<div>Best regards,</div>').description('Signature HTML code').required()
1281
+ }).label('SignatureResponseItem')
1282
+ )
1283
+ .label('SignatureEntries'),
1284
+ signaturesSupported: Joi.boolean()
1285
+ .example(true)
1286
+ .description('Whether the account type supports listing signatures (currently Gmail API accounts only)')
1287
+ }).label('AccountSignaturesResponse'),
1288
+ failAction: 'log'
1289
+ }
1290
+ }
1291
+ });
1292
+
1293
+ server.route({
1294
+ method: 'POST',
1295
+ path: '/v1/authentication/form',
1296
+
1297
+ async handler(request) {
1298
+ try {
1299
+ let { data, signature } = await getSignedFormData({
1300
+ account: request.payload.account,
1301
+ name: request.payload.name,
1302
+ email: request.payload.email,
1303
+ syncFrom: request.payload.syncFrom,
1304
+ notifyFrom: request.payload.notifyFrom,
1305
+ subconnections: request.payload.subconnections,
1306
+ redirectUrl: request.payload.redirectUrl,
1307
+ delegated: request.payload.delegated,
1308
+ path: request.payload.path && !request.payload.path.includes('*') ? request.payload.path : null,
1309
+ // identify request
1310
+ n: crypto.randomBytes(NONCE_BYTES).toString('base64url'),
1311
+ t: Date.now()
1312
+ });
1313
+
1314
+ let serviceUrl = await settings.get('serviceUrl');
1315
+ if (!serviceUrl) {
1316
+ let err = new Error('Service URL not set up');
1317
+ err.code = 'MissingServiceURLSetup';
1318
+ throw err;
1319
+ }
1320
+
1321
+ let url = new URL(`accounts/new`, serviceUrl);
1322
+
1323
+ url.searchParams.append('data', data);
1324
+ if (signature) {
1325
+ url.searchParams.append('sig', signature);
1326
+ }
1327
+
1328
+ let type = request.payload.type;
1329
+
1330
+ if (type && type !== 'imap') {
1331
+ let oauth2app = await oauth2Apps.get(type);
1332
+ if (!oauth2app || !oauth2app.enabled) {
1333
+ type = false;
1334
+ }
1335
+ }
1336
+
1337
+ if (!type) {
1338
+ let oauth2apps = (await oauth2Apps.list(0, 100)).apps.filter(app => app.includeInListing);
1339
+ if (!oauth2apps.length) {
1340
+ type = 'imap';
1341
+ }
1342
+ }
1343
+
1344
+ if (type) {
1345
+ url.searchParams.append('type', type);
1346
+ }
1347
+
1348
+ return {
1349
+ url: url.href
1350
+ };
1351
+ } catch (err) {
1352
+ handleError(request, err);
1353
+ }
1354
+ },
1355
+
1356
+ options: {
1357
+ description: 'Generate authentication link',
1358
+ notes: 'Generates a redirect link to the hosted authentication form',
1359
+ tags: ['api', 'Account'],
1360
+
1361
+ plugins: {
1362
+ 'hapi-swagger': {
1363
+ responses: errorResponses(400, 401, 403, 404, 429, 500)
1364
+ }
1365
+ },
1366
+
1367
+ auth: {
1368
+ strategy: 'api-token',
1369
+ mode: 'required'
1370
+ },
1371
+ cors: CORS_CONFIG,
1372
+
1373
+ validate: {
1374
+ options: {
1375
+ stripUnknown: false,
1376
+ abortEarly: false,
1377
+ convert: true
1378
+ },
1379
+ failAction,
1380
+
1381
+ payload: Joi.object({
1382
+ account: Joi.string()
1383
+ .empty('')
1384
+ .trim()
1385
+ .max(256)
1386
+ .allow(null)
1387
+ .example('example')
1388
+ .default(null)
1389
+ .description(
1390
+ 'Account ID. If set to `null`, a unique ID will be generated automatically. If you provide an existing account ID, the settings for that account will be updated instead'
1391
+ ),
1392
+
1393
+ name: Joi.string().empty('').max(256).example('My Email Account').description('Display name for the account'),
1394
+
1395
+ email: Joi.string()
1396
+ .empty('')
1397
+ .email()
1398
+ .example('user@example.com')
1399
+ .description('Specifies the default email address for this account. Users can change it if needed.'),
1400
+
1401
+ delegated: Joi.boolean()
1402
+ .empty('')
1403
+ .truthy('Y', 'true', '1')
1404
+ .falsy('N', 'false', 0)
1405
+ .default(false)
1406
+ .description('If true, configures this account as a shared mailbox. Currently supported by MS365 OAuth2 accounts'),
1407
+
1408
+ syncFrom: accountSchemas.syncFrom,
1409
+ notifyFrom: accountSchemas.notifyFrom,
1410
+
1411
+ subconnections: accountSchemas.subconnections,
1412
+
1413
+ path: accountPathSchema.example(['*']).label('AccountFormPath'),
1414
+ redirectUrl: Joi.string()
1415
+ .empty('')
1416
+ .uri({ scheme: ['http', 'https'], allowRelative: false })
1417
+ .required()
1418
+ .example('https://myapp/account/settings.php')
1419
+ .description('After the authentication process is completed, the user is redirected to this URL'),
1420
+
1421
+ type: defaultAccountTypeSchema
1422
+ }).label('RequestAuthForm')
1423
+ },
1424
+
1425
+ response: {
1426
+ schema: Joi.object({
1427
+ url: Joi.string()
1428
+ .empty('')
1429
+ .uri({ scheme: ['http', 'https'], allowRelative: false })
1430
+ .required()
1431
+ .example('https://ee.example.com/accounts/new?data=eyJhY2NvdW50IjoiZXhh...L0W_BkFH5HW6Krwmr7c&type=imap')
1432
+ .description('Generated URL to the hosted authentication form')
1433
+ }).label('RequestAuthFormResponse'),
1434
+ failAction: 'log'
1435
+ }
1436
+ }
1437
+ });
1438
+
1439
+ server.route({
1440
+ method: 'GET',
1441
+ path: '/v1/logs/{account}',
1442
+
1443
+ async handler(request) {
1444
+ return getLogs(redis, request.params.account);
1445
+ },
1446
+ options: {
1447
+ description: 'Return IMAP logs for an account',
1448
+ notes: 'Output is a downloadable text file',
1449
+ tags: ['api', 'Logs'],
1450
+
1451
+ auth: {
1452
+ strategy: 'api-token',
1453
+ mode: 'required'
1454
+ },
1455
+ cors: CORS_CONFIG,
1456
+
1457
+ plugins: {
1458
+ 'hapi-swagger': {
1459
+ produces: ['text/plain'],
1460
+ responses: errorResponses(400, 401, 403, 429, 500)
1461
+ }
1462
+ },
1463
+
1464
+ validate: {
1465
+ options: {
1466
+ stripUnknown: false,
1467
+ abortEarly: false,
1468
+ convert: true
1469
+ },
1470
+ failAction,
1471
+
1472
+ params: Joi.object({
1473
+ account: accountIdSchema.required()
1474
+ })
1475
+ }
1476
+ }
1477
+ });
1478
+
1479
+ server.route({
1480
+ method: 'POST',
1481
+ path: '/v1/verifyAccount',
1482
+
1483
+ async handler(request) {
1484
+ try {
1485
+ return await verifyAccountInfo(redis, request.payload, request.logger.child({ action: 'verify-account' }));
1486
+ } catch (err) {
1487
+ handleError(request, err);
1488
+ }
1489
+ },
1490
+ options: {
1491
+ description: 'Verify IMAP and SMTP settings',
1492
+ notes: 'Checks if can connect and authenticate using provided account info',
1493
+ tags: ['api', 'Account'],
1494
+
1495
+ plugins: {
1496
+ 'hapi-swagger': {
1497
+ responses: errorResponses(400, 401, 403, 429, 500)
1498
+ }
1499
+ },
1500
+
1501
+ auth: {
1502
+ strategy: 'api-token',
1503
+ mode: 'required'
1504
+ },
1505
+ cors: CORS_CONFIG,
1506
+
1507
+ validate: {
1508
+ options: {
1509
+ stripUnknown: false,
1510
+ abortEarly: false,
1511
+ convert: true
1512
+ },
1513
+ failAction,
1514
+
1515
+ payload: Joi.object({
1516
+ mailboxes: Joi.boolean().example(false).description('Include mailbox listing in response').default(false).label('IncludeMailboxes'),
1517
+ imap: Joi.object(imapSchema).allow(false).description('IMAP configuration').label('ImapConfiguration'),
1518
+ smtp: Joi.object(smtpSchema).allow(false).description('SMTP configuration').label('SmtpConfiguration'),
1519
+ proxy: settingsSchema.proxyUrl,
1520
+ smtpEhloName: settingsSchema.smtpEhloName
1521
+ }).label('VerifyAccount')
1522
+ },
1523
+ response: {
1524
+ schema: Joi.object({
1525
+ imap: verifyResultSchema('Imap', 'NO [AUTHENTICATIONFAILED] Invalid credentials'),
1526
+ smtp: verifyResultSchema('Smtp', '535 Authentication failed'),
1527
+ mailboxes: shortMailboxesSchema
1528
+ }).label('VerifyAccountResponse'),
1529
+ failAction: 'log'
1530
+ }
1531
+ }
1532
+ });
1533
+
1534
+ server.route({
1535
+ method: 'GET',
1536
+ path: '/v1/autoconfig',
1537
+
1538
+ async handler(request) {
1539
+ try {
1540
+ let serverSettings = await autodetectImapSettings(request.query.email, request.app.gt);
1541
+ return serverSettings;
1542
+ } catch (err) {
1543
+ request.logger.error({ msg: 'API request failed', err });
1544
+ if (Boom.isBoom(err)) {
1545
+ throw err;
1546
+ }
1547
+ return { imap: false, smtp: false, _source: 'unknown' };
1548
+ }
1549
+ },
1550
+
1551
+ options: {
1552
+ description: 'Discover Email settings',
1553
+ notes: 'Try to discover IMAP and SMTP settings for an email account',
1554
+ tags: ['api', 'Settings'],
1555
+
1556
+ plugins: {
1557
+ 'hapi-swagger': {
1558
+ responses: errorResponses(400, 401, 403, 429, 500)
1559
+ }
1560
+ },
1561
+
1562
+ auth: {
1563
+ strategy: 'api-token',
1564
+ mode: 'required'
1565
+ },
1566
+ cors: CORS_CONFIG,
1567
+
1568
+ validate: {
1569
+ options: {
1570
+ stripUnknown: false,
1571
+ abortEarly: false,
1572
+ convert: true
1573
+ },
1574
+ failAction,
1575
+
1576
+ query: Joi.object({
1577
+ email: Joi.string()
1578
+ .email()
1579
+ .required()
1580
+ .example('sender@example.com')
1581
+ .description('Email address to discover email settings for')
1582
+ .label('EmailAddress')
1583
+ }).label('AutodiscoverQuery')
1584
+ },
1585
+
1586
+ response: {
1587
+ schema: Joi.object({
1588
+ imap: Joi.object({
1589
+ auth: Joi.object({
1590
+ user: Joi.string().max(256).example('myuser@gmail.com').description('Account username')
1591
+ }).label('DetectedAuthenticationInfo'),
1592
+
1593
+ host: Joi.string().hostname().example('imap.gmail.com').description('Hostname to connect to'),
1594
+ port: Joi.number()
1595
+ .integer()
1596
+ .min(1)
1597
+ .max(64 * 1024)
1598
+ .example(993)
1599
+ .description('Service port number'),
1600
+ secure: Joi.boolean().default(false).example(true).description('Should connection use TLS. Usually true for port 993')
1601
+ })
1602
+ .allow(false)
1603
+ .description('Discovered IMAP settings. False if IMAP settings were not found')
1604
+ .label('ResolvedServerSettings'),
1605
+ smtp: Joi.object({
1606
+ auth: Joi.object({
1607
+ user: Joi.string().max(256).example('myuser@gmail.com').description('Account username')
1608
+ }).label('DetectedAuthenticationInfo'),
1609
+
1610
+ host: Joi.string().hostname().example('smtp.gmail.com').description('Hostname to connect to'),
1611
+ port: Joi.number()
1612
+ .integer()
1613
+ .min(1)
1614
+ .max(64 * 1024)
1615
+ .example(465)
1616
+ .description('Service port number'),
1617
+ secure: Joi.boolean().default(false).example(true).description('Should connection use TLS. Usually true for port 465')
1618
+ })
1619
+ .allow(false)
1620
+ .description('Discovered SMTP settings. False if SMTP settings were not found')
1621
+ .label('DiscoveredServerSettings'),
1622
+ appPassword: Joi.object({
1623
+ required: Joi.boolean().example(true).description('Whether the provider requires an app password'),
1624
+ provider: Joi.string().example('Gmail').description('Provider name'),
1625
+ instructions: Joi.string()
1626
+ .example('Use an app password instead of the regular account password')
1627
+ .description('Instructions for setting up an app password')
1628
+ })
1629
+ .unknown()
1630
+ .description('App password requirements for the provider, if known')
1631
+ .label('DiscoveredAppPasswordInfo'),
1632
+ _source: Joi.string().example('srv').description('Source for the detected info')
1633
+ }).label('DiscoveredEmailSettings'),
1634
+ failAction: 'log'
1635
+ }
1636
+ }
1637
+ });
1060
1638
  }
1061
1639
 
1062
1640
  module.exports = init;