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
@@ -1,34 +1,51 @@
1
1
  'use strict';
2
2
 
3
+ // Admin UI routes for the remaining /admin/config/* pages: webhooks, service URL/branding,
4
+ // AI (OpenAI) settings, logging, and license. Extracted verbatim from lib/routes-ui.js.
5
+ // The notificationTypes / configWebhooksSchema / configLoggingSchema consts, the
6
+ // getOpenAiError helper, and the getDefaultPrompt helper move with the routes (only these
7
+ // config pages use them).
8
+
3
9
  const Joi = require('joi');
4
10
  const crypto = require('crypto');
11
+ const config = require('@zone-eu/wild-config');
12
+ const libmime = require('libmime');
5
13
  const he = require('he');
14
+ const os = require('os');
15
+ const packageData = require('../../package.json');
6
16
  const { simpleParser } = require('mailparser');
7
- const libmime = require('libmime');
17
+ const { fetch: fetchCmd } = require('undici');
8
18
 
9
19
  const settings = require('../settings');
10
- const { redis, submitQueue, notifyQueue, documentsQueue } = require('../db');
20
+ const consts = require('../consts');
11
21
  const getSecret = require('../get-secret');
22
+ const timezonesList = require('timezones-list').default;
23
+ const { redis, submitQueue, notifyQueue, documentsQueue } = require('../db');
24
+ const { getByteSize, formatByteSize, getDuration, failAction, hasEnvValue, readEnvValue, httpAgent } = require('../tools');
12
25
  const { llmPreProcess } = require('../llm-pre-process');
13
26
  const { locales } = require('../translations');
14
- const consts = require('../consts');
15
- const packageData = require('../../package.json');
16
- const timezonesList = require('timezones-list').default;
27
+ const { settingsSchema } = require('../schemas');
28
+ const { getOpenAiModels, OPEN_AI_MODELS, getExampleDocumentsPayloads } = require('./route-helpers');
17
29
 
18
- const { failAction, getByteSize, formatByteSize, getDuration, readEnvValue, hasEnvValue, httpAgent } = require('../tools');
30
+ const { REDIS_PREFIX, DEFAULT_MAX_LOG_LINES, DEFAULT_DELIVERY_ATTEMPTS, DEFAULT_MAX_BODY_SIZE, DEFAULT_MAX_PAYLOAD_TIMEOUT, NONCE_BYTES } = consts;
19
31
 
20
- const { settingsSchema } = require('../schemas');
32
+ const LICENSE_HOST = 'https://postalsys.com';
21
33
 
22
- const { DEFAULT_MAX_LOG_LINES, DEFAULT_DELIVERY_ATTEMPTS, REDIS_PREFIX, NONCE_BYTES, DEFAULT_GMAIL_EXPORT_BATCH_SIZE, DEFAULT_OUTLOOK_EXPORT_BATCH_SIZE } =
23
- consts;
34
+ config.api = config.api || {
35
+ port: 3000,
36
+ host: '127.0.0.1',
37
+ proxy: false
38
+ };
24
39
 
25
- const { fetch: fetchCmd } = require('undici');
40
+ const MAX_BODY_SIZE = getByteSize(readEnvValue('EENGINE_MAX_BODY_SIZE') || config.api.maxBodySize) || DEFAULT_MAX_BODY_SIZE;
41
+ const MAX_PAYLOAD_TIMEOUT = getDuration(readEnvValue('EENGINE_MAX_PAYLOAD_TIMEOUT') || config.api.maxPayloadTimeout) || DEFAULT_MAX_PAYLOAD_TIMEOUT;
26
42
 
27
- const OPEN_AI_MODELS = [
28
- { name: 'GPT-3 (instruct)', id: 'gpt-3.5-turbo-instruct' },
29
- { name: 'GPT-3 (chat)', id: 'gpt-3.5-turbo' },
30
- { name: 'GPT-4', id: 'gpt-4' }
31
- ];
43
+ const ADMIN_ACCESS_ADDRESSES = hasEnvValue('EENGINE_ADMIN_ACCESS_ADDRESSES')
44
+ ? readEnvValue('EENGINE_ADMIN_ACCESS_ADDRESSES')
45
+ .split(',')
46
+ .map(v => v.trim())
47
+ .filter(v => v)
48
+ : null;
32
49
 
33
50
  const IMAP_INDEXERS = [
34
51
  {
@@ -55,30 +72,25 @@ const notificationTypes = Object.keys(consts)
55
72
  description: consts[`${key}_DESCRIPTION`]
56
73
  }));
57
74
 
58
- const ADMIN_ACCESS_ADDRESSES = hasEnvValue('EENGINE_ADMIN_ACCESS_ADDRESSES')
59
- ? readEnvValue('EENGINE_ADMIN_ACCESS_ADDRESSES')
60
- .split(',')
61
- .map(v => v.trim())
62
- .filter(v => v)
63
- : null;
64
-
65
- const MAX_BODY_SIZE = getByteSize(readEnvValue('EENGINE_MAX_BODY_SIZE')) || consts.DEFAULT_MAX_BODY_SIZE;
66
- const MAX_PAYLOAD_TIMEOUT = getDuration(readEnvValue('EENGINE_MAX_PAYLOAD_TIMEOUT')) || consts.DEFAULT_MAX_PAYLOAD_TIMEOUT;
67
-
68
- // Validation schemas
69
75
  const configWebhooksSchema = {
70
- webhooksEnabled: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
76
+ webhooksEnabled: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false).description('Enable Webhooks'),
71
77
  webhooks: Joi.string()
72
- .uri({ scheme: ['http', 'https'], allowRelative: false })
78
+ .uri({
79
+ scheme: ['http', 'https'],
80
+ allowRelative: false
81
+ })
73
82
  .allow('')
74
- .example('https://myservice.com/imap/webhooks'),
83
+ .example('https://myservice.com/imap/webhooks')
84
+ .description('Webhook URL'),
75
85
  notifyAll: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
76
86
  headersAll: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
77
87
  notifyHeaders: Joi.string().empty('').trim(),
78
88
  notifyText: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
79
89
  notifyWebSafeHtml: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
90
+
80
91
  notifyTextSize: Joi.alternatives().try(
81
92
  Joi.number().empty('').integer().min(0),
93
+ // If it's a string, parse and convert it to bytes
82
94
  Joi.string().custom((value, helpers) => {
83
95
  let nr = getByteSize(value);
84
96
  if (typeof nr !== 'number' || nr < 0) {
@@ -87,11 +99,14 @@ const configWebhooksSchema = {
87
99
  return nr;
88
100
  }, 'Byte size conversion')
89
101
  ),
102
+
90
103
  notifyCalendarEvents: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
91
104
  inboxNewOnly: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
105
+
92
106
  notifyAttachments: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
93
107
  notifyAttachmentSize: Joi.alternatives().try(
94
108
  Joi.number().empty('').integer().min(0),
109
+ // If it's a string, parse and convert it to bytes
95
110
  Joi.string().custom((value, helpers) => {
96
111
  let nr = getByteSize(value);
97
112
  if (typeof nr !== 'number' || nr < 0) {
@@ -100,10 +115,12 @@ const configWebhooksSchema = {
100
115
  return nr;
101
116
  }, 'Byte size conversion')
102
117
  ),
118
+
103
119
  customHeaders: Joi.string()
104
120
  .allow('')
105
121
  .trim()
106
122
  .max(10 * 1024)
123
+ .description('Custom request headers')
107
124
  };
108
125
 
109
126
  for (let type of notificationTypes) {
@@ -111,47 +128,27 @@ for (let type of notificationTypes) {
111
128
  }
112
129
 
113
130
  const configLoggingSchema = {
114
- all: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
115
- maxLogLines: Joi.number().integer().empty('').min(0).max(10000000).default(DEFAULT_MAX_LOG_LINES)
131
+ all: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false).description('Enable logs for all accounts'),
132
+ maxLogLines: Joi.number().integer().empty('').min(0).max(10000000).default(DEFAULT_MAX_LOG_LINES),
133
+ sentryEnabled: settingsSchema.sentryEnabled.default(false),
134
+ sentryDsn: settingsSchema.sentryDsn.default('')
116
135
  };
117
136
 
118
- // Helper functions
119
- async function getOpenAiModels(models, selectedModel) {
120
- let modelList = (await settings.get('openAiModels')) || structuredClone(models);
121
-
122
- if (selectedModel && !modelList.find(model => model.id === selectedModel)) {
123
- modelList.unshift({ name: selectedModel, id: selectedModel });
124
- }
125
-
126
- return modelList.map(model => {
127
- model.selected = model.id === selectedModel;
128
- return model;
129
- });
130
- }
131
-
132
137
  async function getOpenAiError(gt) {
133
- let openAiErrorData = await redis.get(`${REDIS_PREFIX}:openai:error`);
134
- if (openAiErrorData) {
138
+ let openAiError = await redis.get(`${REDIS_PREFIX}:openai:error`);
139
+ if (openAiError) {
135
140
  try {
136
- let { error, time } = JSON.parse(openAiErrorData);
137
- return { error, time: (gt && gt.dateFns.formatDistance(new Date(time), new Date(), { addSuffix: true })) || time };
141
+ openAiError = JSON.parse(openAiError);
142
+ switch (openAiError.code) {
143
+ case 'invalid_api_key':
144
+ openAiError.message = gt.gettext('Invalid API key for OpenAI');
145
+ break;
146
+ }
138
147
  } catch (err) {
139
- return false;
140
- }
141
- }
142
- return false;
143
- }
144
-
145
- async function getExampleDocumentsPayloads() {
146
- const exampleDocumentsPayloads = require('../payload-examples-documents.json');
147
- let examples = structuredClone(exampleDocumentsPayloads);
148
- let serviceUrl = await settings.get('serviceUrl');
149
- for (let example of examples) {
150
- if (example.serviceUrl) {
151
- example.serviceUrl = serviceUrl || example.serviceUrl;
148
+ openAiError = null;
152
149
  }
153
150
  }
154
- return examples;
151
+ return openAiError;
155
152
  }
156
153
 
157
154
  function init(args) {
@@ -162,7 +159,6 @@ function init(args) {
162
159
  cmd: 'openAiDefaultPrompt'
163
160
  });
164
161
 
165
- // Webhooks config routes
166
162
  server.route({
167
163
  method: 'GET',
168
164
  path: '/admin/config/webhooks',
@@ -173,8 +169,10 @@ function init(args) {
173
169
  const notifyWebSafeHtml = (await settings.get('notifyWebSafeHtml')) || false;
174
170
  const notifyTextSize = Number(await settings.get('notifyTextSize')) || 0;
175
171
  const notifyCalendarEvents = (await settings.get('notifyCalendarEvents')) || false;
172
+
176
173
  const notifyAttachments = (await settings.get('notifyAttachments')) || false;
177
174
  const notifyAttachmentSize = Number(await settings.get('notifyAttachmentSize')) || 0;
175
+
178
176
  const inboxNewOnly = (await settings.get('inboxNewOnly')) || false;
179
177
  const customHeaders = (await settings.get('webhooksCustomHeaders')) || [];
180
178
 
@@ -182,8 +180,10 @@ function init(args) {
182
180
  let values = {
183
181
  webhooksEnabled: webhooksEnabled !== null ? !!webhooksEnabled : false,
184
182
  webhooks: (await settings.get('webhooks')) || '',
183
+
185
184
  notifyAll: webhookEvents.includes('*'),
186
185
  inboxNewOnly,
186
+
187
187
  headersAll: notifyHeaders.includes('*'),
188
188
  notifyHeaders: notifyHeaders
189
189
  .filter(entry => entry !== '*')
@@ -193,8 +193,10 @@ function init(args) {
193
193
  notifyWebSafeHtml,
194
194
  notifyTextSize: notifyTextSize ? formatByteSize(notifyTextSize) : '',
195
195
  notifyCalendarEvents,
196
+
196
197
  notifyAttachments,
197
198
  notifyAttachmentSize: notifyAttachmentSize ? formatByteSize(notifyAttachmentSize) : '',
199
+
198
200
  customHeaders: []
199
201
  .concat(customHeaders || [])
200
202
  .map(entry => `${entry.key}: ${entry.value}`.trim())
@@ -207,14 +209,19 @@ function init(args) {
207
209
  pageTitle: 'Webhooks',
208
210
  menuConfig: true,
209
211
  menuConfigWebhooks: true,
212
+
210
213
  notificationTypes: notificationTypes.map(type =>
211
214
  Object.assign({}, type, { checked: webhookEvents.includes(type.name), isMessageNew: type.name === 'messageNew' })
212
215
  ),
216
+
213
217
  values,
218
+
214
219
  webhookErrorFlag: await settings.get('webhookErrorFlag'),
215
220
  documentStoreEnabled: (await settings.get('documentStoreEnabled')) || false
216
221
  },
217
- { layout: 'app' }
222
+ {
223
+ layout: 'app'
224
+ }
218
225
  );
219
226
  }
220
227
  });
@@ -231,9 +238,15 @@ function init(args) {
231
238
  .map(line => {
232
239
  let sep = line.indexOf(':');
233
240
  if (sep >= 0) {
234
- return { key: line.substring(0, sep).trim(), value: line.substring(sep + 1).trim() };
241
+ return {
242
+ key: line.substring(0, sep).trim(),
243
+ value: line.substring(sep + 1).trim()
244
+ };
235
245
  }
236
- return { key: line, value: '' };
246
+ return {
247
+ key: line,
248
+ value: ''
249
+ };
237
250
  });
238
251
 
239
252
  const data = {
@@ -243,14 +256,18 @@ function init(args) {
243
256
  notifyWebSafeHtml: request.payload.notifyWebSafeHtml,
244
257
  notifyTextSize: request.payload.notifyTextSize || 0,
245
258
  notifyCalendarEvents: request.payload.notifyCalendarEvents,
259
+
246
260
  notifyAttachments: request.payload.notifyAttachments,
247
261
  notifyAttachmentSize: request.payload.notifyAttachmentSize,
262
+
248
263
  inboxNewOnly: request.payload.inboxNewOnly,
264
+
249
265
  webhookEvents: notificationTypes.filter(type => !!request.payload[`notify_${type.name}`]).map(type => type.name),
250
266
  notifyHeaders: (request.payload.notifyHeaders || '')
251
267
  .split(/\r?\n/)
252
268
  .map(line => line.toLowerCase().trim())
253
269
  .filter(line => line),
270
+
254
271
  webhooksCustomHeaders: customHeaders
255
272
  };
256
273
 
@@ -267,10 +284,12 @@ function init(args) {
267
284
  }
268
285
 
269
286
  if (!data.webhooksEnabled) {
287
+ // clear error message (if exists)
270
288
  await settings.clear('webhookErrorFlag');
271
289
  }
272
290
 
273
291
  await request.flash({ type: 'info', message: `Configuration updated` });
292
+
274
293
  return h.redirect('/admin/config/webhooks');
275
294
  } catch (err) {
276
295
  await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
@@ -282,21 +301,31 @@ function init(args) {
282
301
  pageTitle: 'Webhooks',
283
302
  menuConfig: true,
284
303
  menuConfigWebhooks: true,
304
+
285
305
  notificationTypes: notificationTypes.map(type =>
286
306
  Object.assign({}, type, { checked: !!request.payload[`notify_${type.name}`], isMessageNew: type.name === 'messageNew' })
287
307
  ),
308
+
288
309
  webhookErrorFlag: await settings.get('webhookErrorFlag'),
289
310
  documentStoreEnabled: (await settings.get('documentStoreEnabled')) || false
290
311
  },
291
- { layout: 'app' }
312
+ {
313
+ layout: 'app'
314
+ }
292
315
  );
293
316
  }
294
317
  },
295
318
  options: {
296
319
  validate: {
297
- options: { stripUnknown: true, abortEarly: false, convert: true },
320
+ options: {
321
+ stripUnknown: true,
322
+ abortEarly: false,
323
+ convert: true
324
+ },
325
+
298
326
  async failAction(request, h, err) {
299
327
  let errors = {};
328
+
300
329
  if (err.details) {
301
330
  err.details.forEach(detail => {
302
331
  if (!errors[detail.path]) {
@@ -315,6 +344,7 @@ function init(args) {
315
344
  pageTitle: 'Webhooks',
316
345
  menuConfig: true,
317
346
  menuConfigWebhooks: true,
347
+
318
348
  notificationTypes: notificationTypes.map(type =>
319
349
  Object.assign({}, type, {
320
350
  checked: !!request.payload[`notify_${type.name}`],
@@ -322,20 +352,24 @@ function init(args) {
322
352
  error: errors[`notify_${type.name}`]
323
353
  })
324
354
  ),
355
+
325
356
  errors,
357
+
326
358
  webhookErrorFlag: await settings.get('webhookErrorFlag'),
327
359
  documentStoreEnabled: (await settings.get('documentStoreEnabled')) || false
328
360
  },
329
- { layout: 'app' }
361
+ {
362
+ layout: 'app'
363
+ }
330
364
  )
331
365
  .takeover();
332
366
  },
367
+
333
368
  payload: Joi.object(configWebhooksSchema)
334
369
  }
335
370
  }
336
371
  });
337
372
 
338
- // Service config routes
339
373
  server.route({
340
374
  method: 'GET',
341
375
  path: '/admin/config/service',
@@ -347,22 +381,26 @@ function init(args) {
347
381
  serviceSecret: (await settings.get('serviceSecret')) || null,
348
382
  queueKeep: (await settings.get('queueKeep')) || 0,
349
383
  deliveryAttempts: await settings.get('deliveryAttempts'),
384
+
350
385
  imapIndexer: (await settings.get('imapIndexer')) || 'full',
386
+
351
387
  pageBrandName: (await settings.get('pageBrandName')) || '',
352
388
  templateHeader: (await settings.get('templateHeader')) || '',
353
389
  templateHtmlHead: (await settings.get('templateHtmlHead')) || '',
354
390
  scriptEnv: (await settings.get('scriptEnv')) || '',
355
391
  enableTokens: !(await settings.get('disableTokens')),
356
392
  enableApiProxy: (await settings.get('enableApiProxy')) || false,
393
+
357
394
  trackClicks: await settings.get('trackClicks'),
358
395
  trackOpens: await settings.get('trackOpens'),
396
+
359
397
  resolveGmailCategories: (await settings.get('resolveGmailCategories')) || false,
360
398
  enableOAuthTokensApi: (await settings.get('enableOAuthTokensApi')) || false,
399
+
361
400
  ignoreMailCertErrors: (await settings.get('ignoreMailCertErrors')) || false,
401
+
362
402
  locale: (await settings.get('locale')) || false,
363
- timezone: (await settings.get('timezone')) || false,
364
- gmailExportBatchSize: (await settings.get('gmailExportBatchSize')) || DEFAULT_GMAIL_EXPORT_BATCH_SIZE,
365
- outlookExportBatchSize: (await settings.get('outlookExportBatchSize')) || DEFAULT_OUTLOOK_EXPORT_BATCH_SIZE
403
+ timezone: (await settings.get('timezone')) || false
366
404
  };
367
405
 
368
406
  if (typeof values.trackClicks !== 'boolean') {
@@ -385,21 +423,27 @@ function init(args) {
385
423
  menuConfigService: true,
386
424
  encryption: await getSecret(),
387
425
  locales: locales.map(locale => Object.assign({ selected: locale.locale === values.locale }, locale)),
426
+
388
427
  timezones: timezonesList.map(entry => ({
389
428
  name: entry.label,
390
429
  timezone: entry.tzCode,
391
430
  selected: entry.tzCode === values.timezone
392
431
  })),
432
+
393
433
  imapIndexers: structuredClone(IMAP_INDEXERS).map(entry => {
394
434
  if (entry.id === values.imapIndexer) {
395
435
  entry.selected = true;
396
436
  }
397
437
  return entry;
398
438
  }),
439
+
399
440
  adminAccessLimit: ADMIN_ACCESS_ADDRESSES && ADMIN_ACCESS_ADDRESSES.length,
441
+
400
442
  values
401
443
  },
402
- { layout: 'app' }
444
+ {
445
+ layout: 'app'
446
+ }
403
447
  );
404
448
  }
405
449
  });
@@ -426,9 +470,7 @@ function init(args) {
426
470
  locale: request.payload.locale,
427
471
  timezone: request.payload.timezone,
428
472
  deliveryAttempts: request.payload.deliveryAttempts,
429
- imapIndexer: request.payload.imapIndexer,
430
- gmailExportBatchSize: request.payload.gmailExportBatchSize,
431
- outlookExportBatchSize: request.payload.outlookExportBatchSize
473
+ imapIndexer: request.payload.imapIndexer
432
474
  };
433
475
 
434
476
  if (request.payload.serviceUrl) {
@@ -441,6 +483,7 @@ function init(args) {
441
483
  }
442
484
 
443
485
  await request.flash({ type: 'info', message: `Configuration updated` });
486
+
444
487
  return h.redirect('/admin/config/service');
445
488
  } catch (err) {
446
489
  await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
@@ -454,28 +497,39 @@ function init(args) {
454
497
  menuConfigService: true,
455
498
  locales: locales.map(locale => Object.assign({ selected: locale.locale === request.payload.locale }, locale)),
456
499
  encryption: await getSecret(),
500
+
457
501
  timezones: timezonesList.map(entry => ({
458
502
  name: entry.label,
459
503
  timezone: entry.tzCode,
460
504
  selected: entry.tzCode === request.payload.timezone
461
505
  })),
506
+
462
507
  imapIndexers: structuredClone(IMAP_INDEXERS).map(entry => {
463
508
  if (entry.id === request.payload.imapIndexer) {
464
509
  entry.selected = true;
465
510
  }
466
511
  return entry;
467
512
  }),
513
+
468
514
  adminAccessLimit: ADMIN_ACCESS_ADDRESSES && ADMIN_ACCESS_ADDRESSES.length
469
515
  },
470
- { layout: 'app' }
516
+ {
517
+ layout: 'app'
518
+ }
471
519
  );
472
520
  }
473
521
  },
474
522
  options: {
475
523
  validate: {
476
- options: { stripUnknown: true, abortEarly: false, convert: true },
524
+ options: {
525
+ stripUnknown: true,
526
+ abortEarly: false,
527
+ convert: true
528
+ },
529
+
477
530
  async failAction(request, h, err) {
478
531
  let errors = {};
532
+
479
533
  if (err.details) {
480
534
  err.details.forEach(detail => {
481
535
  if (!errors[detail.path]) {
@@ -496,24 +550,31 @@ function init(args) {
496
550
  menuConfigService: true,
497
551
  locales: locales.map(locale => Object.assign({ selected: locale.locale === request.payload.locale }, locale)),
498
552
  encryption: await getSecret(),
553
+
499
554
  timezones: timezonesList.map(entry => ({
500
555
  name: entry.label,
501
556
  timezone: entry.tzCode,
502
557
  selected: entry.tzCode === request.payload.timezone
503
558
  })),
559
+
504
560
  imapIndexers: structuredClone(IMAP_INDEXERS).map(entry => {
505
561
  if (entry.id === request.payload.imapIndexer) {
506
562
  entry.selected = true;
507
563
  }
508
564
  return entry;
509
565
  }),
566
+
510
567
  adminAccessLimit: ADMIN_ACCESS_ADDRESSES && ADMIN_ACCESS_ADDRESSES.length,
568
+
511
569
  errors
512
570
  },
513
- { layout: 'app' }
571
+ {
572
+ layout: 'app'
573
+ }
514
574
  )
515
575
  .takeover();
516
576
  },
577
+
517
578
  payload: Joi.object({
518
579
  serviceUrl: settingsSchema.serviceUrl,
519
580
  serviceSecret: settingsSchema.serviceSecret,
@@ -529,154 +590,583 @@ function init(args) {
529
590
  trackClicks: settingsSchema.trackClicks.default(false),
530
591
  resolveGmailCategories: settingsSchema.resolveGmailCategories.default(false),
531
592
  ignoreMailCertErrors: settingsSchema.ignoreMailCertErrors.default(false),
532
- enableOAuthTokensApi: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
593
+
594
+ // Following options can only be changed via the UI
595
+ enableOAuthTokensApi: Joi.boolean()
596
+ .truthy('Y', 'true', '1', 'on')
597
+ .falsy('N', 'false', 0, '')
598
+ .description('If true, then allow using using the OAuth tokens API endpoint')
599
+ .default(false),
533
600
  enableTokens: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
601
+
534
602
  locale: settingsSchema.locale
535
603
  .empty('')
536
604
  .valid(...locales.map(locale => locale.locale))
537
605
  .default('en'),
538
- timezone: settingsSchema.timezone.empty(''),
539
- gmailExportBatchSize: settingsSchema.gmailExportBatchSize.default(DEFAULT_GMAIL_EXPORT_BATCH_SIZE),
540
- outlookExportBatchSize: settingsSchema.outlookExportBatchSize.default(DEFAULT_OUTLOOK_EXPORT_BATCH_SIZE)
606
+
607
+ timezone: settingsSchema.timezone.empty('')
541
608
  })
542
609
  }
543
610
  }
544
611
  });
545
612
 
546
- // Service preview route
547
613
  server.route({
548
- method: 'POST',
549
- path: '/admin/config/service/preview',
614
+ method: 'GET',
615
+ path: '/admin/config/ai',
550
616
  async handler(request, h) {
551
- return h.view(
552
- 'config/service-preview',
553
- {
554
- pageBrandName: request.payload.pageBrandName,
555
- embeddedTemplateHeader: request.payload.templateHeader,
556
- embeddedTemplateHtmlHead: request.payload.templateHtmlHead
557
- },
558
- { layout: 'public' }
559
- );
560
- },
561
- options: {
562
- validate: {
563
- options: { stripUnknown: true, abortEarly: false, convert: true },
564
- async failAction(request, h, err) {
565
- request.logger.error({ msg: 'Failed to process preview', err });
566
- return h.redirect('/admin').takeover();
567
- },
568
- payload: Joi.object({
569
- pageBrandName: settingsSchema.pageBrandName.default(''),
570
- templateHeader: settingsSchema.templateHeader.default(''),
571
- templateHtmlHead: settingsSchema.templateHtmlHead.default('')
572
- })
573
- }
574
- }
575
- });
617
+ const errorLog = ((await llmPreProcess.getErrorLog()) || []).map(entry => {
618
+ if (entry.error && typeof entry.error === 'string') {
619
+ entry.error = entry.error
620
+ .replace(/\r?\n/g, '\n')
621
+ .replace(/^\s+at\s+.*$/gm, '')
622
+ .replace(/\n+/g, '\n')
623
+ .trim()
624
+ .replace(/(evalmachine.<anonymous>:)(\d+)/, (o, p, n) => p + (Number(n) - 1));
625
+ }
626
+ return entry;
627
+ });
576
628
 
577
- // Clear error route
578
- server.route({
579
- method: 'POST',
580
- path: '/admin/config/clear-error',
581
- async handler(request) {
582
- switch (request.payload.alert) {
583
- case 'open-ai':
584
- await redis.del(`${REDIS_PREFIX}:openai:error`);
585
- break;
586
- case 'webhook-default':
587
- await settings.clear('webhookErrorFlag');
588
- break;
589
- case 'webhook-route':
590
- if (request.payload.entry) {
591
- await redis.hdel(`${REDIS_PREFIX}wh:c`, `${request.payload.entry}:webhookErrorFlag`);
592
- }
593
- break;
594
- }
595
- return { success: true };
596
- },
597
- options: {
598
- validate: {
599
- options: { stripUnknown: true, abortEarly: false, convert: true },
600
- failAction,
601
- payload: Joi.object({
602
- alert: Joi.string().required().max(1024),
603
- entry: Joi.string().empty('').max(1024).trim()
604
- })
605
- }
606
- }
607
- });
629
+ const values = {
630
+ generateEmailSummary: (await settings.get('generateEmailSummary')) || false,
631
+ openAiPrompt: ((await settings.get('openAiPrompt')) || '').toString(),
632
+ contentFnJson: JSON.stringify(
633
+ ((await settings.get(`openAiPreProcessingFn`)) || '').toString() ||
634
+ `// Pass all emails
635
+ return true;`
636
+ ),
608
637
 
609
- // Service clean route
610
- server.route({
611
- method: 'POST',
612
- path: '/admin/config/service/clean',
613
- async handler(request) {
614
- let errors = [];
615
- for (let queue of [submitQueue, notifyQueue, documentsQueue]) {
616
- for (let type of ['failed', 'completed']) {
617
- try {
618
- await queue.clean(1000, 100000, type);
619
- request.logger.trace({ msg: 'Queue cleaned', queue: queue.name, type });
620
- } catch (err) {
621
- request.logger.error({ msg: 'Failed to clean queue', queue: queue.name, type, err });
622
- errors.push(err.message);
623
- }
624
- }
625
- }
638
+ openAiAPIUrl: ((await settings.get('openAiAPIUrl')) || '').toString(),
626
639
 
627
- if (errors.length) {
628
- return { success: false, error: 'Cleaning failed for some queues' };
629
- }
630
- return { success: true };
631
- },
632
- options: {
633
- validate: {
634
- options: { stripUnknown: true, abortEarly: false, convert: true }
635
- }
636
- }
637
- });
640
+ openAiTemperature: ((await settings.get('openAiTemperature')) || '').toString(),
641
+ openAiTopP: ((await settings.get('openAiTopP')) || '').toString(),
642
+ openAiMaxTokens: ((await settings.get('openAiMaxTokens')) || '').toString()
643
+ };
638
644
 
639
- // Logging config routes
640
- server.route({
641
- method: 'GET',
642
- path: '/admin/config/logging',
643
- async handler(request, h) {
644
- let values = (await settings.get('logs')) || {};
645
- if (typeof values.maxLogLines === 'undefined') {
646
- values.maxLogLines = DEFAULT_MAX_LOG_LINES;
645
+ if (!values.openAiPrompt.trim()) {
646
+ values.openAiPrompt = await getDefaultPrompt();
647
647
  }
648
- values.accounts = [].concat(values.accounts || []).join('\n');
648
+
649
+ let hasOpenAiAPIKey = !!(await settings.get('openAiAPIKey'));
650
+ let openAiModel = await settings.get('openAiModel');
651
+ let openAiError = await getOpenAiError(request.app.gt);
649
652
 
650
653
  return h.view(
651
- 'config/logging',
654
+ 'config/ai',
652
655
  {
653
- pageTitle: 'Logging',
656
+ pageTitle: 'AI Processing',
654
657
  menuConfig: true,
655
- menuConfigLogging: true,
656
- values
658
+ menuConfigAi: true,
659
+
660
+ errorLog,
661
+ defaultPromptJson: JSON.stringify({ prompt: await getDefaultPrompt() }),
662
+
663
+ values,
664
+
665
+ hasOpenAiAPIKey,
666
+ openAiError,
667
+ openAiModels: await getOpenAiModels(OPEN_AI_MODELS, openAiModel),
668
+
669
+ scriptEnvJson: JSON.stringify((await settings.get('scriptEnv')) || '{}'),
670
+ examplePayloadsJson: JSON.stringify(
671
+ (await getExampleDocumentsPayloads()).map(entry =>
672
+ Object.assign({}, entry, { summary: undefined, riskAssessment: undefined, preview: undefined })
673
+ )
674
+ )
657
675
  },
658
- { layout: 'app' }
676
+ {
677
+ layout: 'app'
678
+ }
659
679
  );
660
680
  }
661
681
  });
662
682
 
663
683
  server.route({
664
684
  method: 'POST',
665
- path: '/admin/config/logging',
685
+ path: '/admin/config/ai',
666
686
  async handler(request, h) {
667
687
  try {
668
- const data = {
669
- logs: {
688
+ let contentFn;
689
+ try {
690
+ if (request.payload.contentFnJson === '') {
691
+ contentFn = null;
692
+ } else {
693
+ contentFn = JSON.parse(request.payload.contentFnJson);
694
+ if (typeof contentFn !== 'string') {
695
+ throw new Error('Invalid Format');
696
+ }
697
+ }
698
+ } catch (err) {
699
+ err.details = {
700
+ contentFnJson: 'Invalid JSON'
701
+ };
702
+ throw err;
703
+ }
704
+
705
+ let data = {
706
+ generateEmailSummary: request.payload.generateEmailSummary,
707
+ openAiModel: request.payload.openAiModel,
708
+ openAiAPIUrl: request.payload.openAiAPIUrl,
709
+ openAiPrompt: (request.payload.openAiPrompt || '').toString(),
710
+ openAiPreProcessingFn: contentFn,
711
+ openAiTemperature: request.payload.openAiTemperature,
712
+ openAiTopP: request.payload.openAiTopP,
713
+ openAiMaxTokens: request.payload.openAiMaxTokens
714
+ };
715
+
716
+ let defaultUserPrompt = await getDefaultPrompt();
717
+
718
+ if (!data.openAiPrompt.trim() || data.openAiPrompt.trim() === defaultUserPrompt.trim()) {
719
+ data.openAiPrompt = '';
720
+ }
721
+
722
+ if (typeof request.payload.openAiAPIKey === 'string') {
723
+ data.openAiAPIKey = request.payload.openAiAPIKey;
724
+ }
725
+
726
+ if (typeof request.payload.openAiAPIUrl === 'string') {
727
+ data.openAiAPIUrl = request.payload.openAiAPIUrl;
728
+ }
729
+
730
+ for (let key of Object.keys(data)) {
731
+ await settings.set(key, data[key]);
732
+ }
733
+
734
+ await request.flash({ type: 'info', message: `Configuration updated` });
735
+
736
+ return h.redirect('/admin/config/ai');
737
+ } catch (err) {
738
+ await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
739
+ request.logger.error({ msg: 'Failed to update configuration', err });
740
+
741
+ let hasOpenAiAPIKey = !!(await settings.get('openAiAPIKey'));
742
+ let openAiError = await getOpenAiError(request.app.gt);
743
+
744
+ const errorLog = ((await llmPreProcess.getErrorLog()) || []).map(entry => {
745
+ if (entry.error && typeof entry.error === 'string') {
746
+ entry.error = entry.error
747
+ .replace(/\r?\n/g, '\n')
748
+ .replace(/^\s+at\s+.*$/gm, '')
749
+ .replace(/\n+/g, '\n')
750
+ .trim()
751
+ .replace(/(evalmachine.<anonymous>:)(\d+)/, (o, p, n) => p + (Number(n) - 1));
752
+ }
753
+ return entry;
754
+ });
755
+
756
+ return h.view(
757
+ 'config/ai',
758
+ {
759
+ pageTitle: 'AI Processing',
760
+ menuConfig: true,
761
+ menuConfigAi: true,
762
+
763
+ errorLog,
764
+ defaultPromptJson: JSON.stringify({ prompt: await getDefaultPrompt() }),
765
+
766
+ hasOpenAiAPIKey,
767
+ openAiError,
768
+ openAiModels: await getOpenAiModels(OPEN_AI_MODELS, request.payload.openAiModel),
769
+
770
+ scriptEnvJson: JSON.stringify((await settings.get('scriptEnv')) || '{}'),
771
+ examplePayloadsJson: JSON.stringify(
772
+ (await getExampleDocumentsPayloads()).map(entry =>
773
+ Object.assign({}, entry, { summary: undefined, riskAssessment: undefined, preview: undefined })
774
+ )
775
+ )
776
+ },
777
+ {
778
+ layout: 'app'
779
+ }
780
+ );
781
+ }
782
+ },
783
+ options: {
784
+ validate: {
785
+ options: {
786
+ stripUnknown: true,
787
+ abortEarly: false,
788
+ convert: true
789
+ },
790
+
791
+ async failAction(request, h, err) {
792
+ let errors = {};
793
+
794
+ if (err.details) {
795
+ err.details.forEach(detail => {
796
+ if (!errors[detail.path]) {
797
+ errors[detail.path] = detail.message;
798
+ }
799
+ });
800
+ }
801
+
802
+ await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
803
+ request.logger.error({ msg: 'Failed to update configuration', err });
804
+
805
+ let hasOpenAiAPIKey = !!(await settings.get('openAiAPIKey'));
806
+ let openAiError = await getOpenAiError(request.app.gt);
807
+
808
+ const errorLog = ((await llmPreProcess.getErrorLog()) || []).map(entry => {
809
+ if (entry.error && typeof entry.error === 'string') {
810
+ entry.error = entry.error
811
+ .replace(/\r?\n/g, '\n')
812
+ .replace(/^\s+at\s+.*$/gm, '')
813
+ .replace(/\n+/g, '\n')
814
+ .trim()
815
+ .replace(/(evalmachine.<anonymous>:)(\d+)/, (o, p, n) => p + (Number(n) - 1));
816
+ }
817
+ return entry;
818
+ });
819
+
820
+ return h
821
+ .view(
822
+ 'config/ai',
823
+ {
824
+ pageTitle: 'AI Processing',
825
+ menuConfig: true,
826
+ menuConfigAi: true,
827
+
828
+ errorLog,
829
+ defaultPromptJson: JSON.stringify({ prompt: await getDefaultPrompt() }),
830
+
831
+ hasOpenAiAPIKey,
832
+ openAiError,
833
+ openAiModels: await getOpenAiModels(OPEN_AI_MODELS, request.payload.openAiModel),
834
+
835
+ scriptEnvJson: JSON.stringify((await settings.get('scriptEnv')) || '{}'),
836
+ examplePayloadsJson: JSON.stringify(
837
+ (await getExampleDocumentsPayloads()).map(entry =>
838
+ Object.assign({}, entry, { summary: undefined, riskAssessment: undefined, preview: undefined })
839
+ )
840
+ ),
841
+
842
+ errors
843
+ },
844
+ {
845
+ layout: 'app'
846
+ }
847
+ )
848
+ .takeover();
849
+ },
850
+
851
+ payload: Joi.object({
852
+ generateEmailSummary: settingsSchema.generateEmailSummary.default(false),
853
+ openAiAPIKey: settingsSchema.openAiAPIKey.empty(''),
854
+ openAiModel: settingsSchema.openAiModel.empty(''),
855
+
856
+ openAiAPIUrl: settingsSchema.openAiAPIUrl.default(''),
857
+
858
+ openAiPrompt: settingsSchema.openAiPrompt.default(''),
859
+
860
+ contentFnJson: Joi.string()
861
+ .max(1024 * 1024)
862
+ .default('')
863
+ .allow('')
864
+ .trim()
865
+ .description('Filter function'),
866
+
867
+ openAiTemperature: settingsSchema.openAiTemperature.default(''),
868
+ openAiTopP: settingsSchema.openAiTopP.default(''),
869
+ openAiMaxTokens: settingsSchema.openAiMaxTokens.default('')
870
+ })
871
+ }
872
+ }
873
+ });
874
+
875
+ server.route({
876
+ method: 'POST',
877
+ path: '/admin/config/ai/test-prompt',
878
+ async handler(request) {
879
+ try {
880
+ request.logger.info({ msg: 'Prompt test' });
881
+
882
+ const parsed = await simpleParser(Buffer.from(request.payload.emailFile, 'base64'));
883
+
884
+ const response = {};
885
+ response.summary = await call({
886
+ cmd: 'generateSummary',
887
+ data: {
888
+ message: {
889
+ headers: parsed.headerLines.map(header => libmime.decodeHeader(header.line)),
890
+ attachments: parsed.attachments,
891
+ html: parsed.html,
892
+ text: parsed.text
893
+ },
894
+ openAiAPIKey: request.payload.openAiAPIKey,
895
+ openAiModel: request.payload.openAiModel,
896
+ openAiAPIUrl: request.payload.openAiAPIUrl,
897
+ openAiPrompt: request.payload.openAiPrompt,
898
+ openAiTemperature: request.payload.openAiTemperature,
899
+ openAiTopP: request.payload.openAiTopP,
900
+ openAiMaxTokens: request.payload.openAiMaxTokens
901
+ },
902
+ timeout: 2 * 60 * 1000
903
+ });
904
+
905
+ // crux from olden times
906
+ for (let key of Object.keys(response.summary)) {
907
+ // remove meta keys from output
908
+ if (key.charAt(0) === '_' || response.summary[key] === '') {
909
+ delete response.summary[key];
910
+ }
911
+ if (key === 'riskAssessment') {
912
+ response.riskAssessment = response.summary.riskAssessment;
913
+ delete response.summary.riskAssessment;
914
+ }
915
+ }
916
+
917
+ return { success: true, response };
918
+ } catch (err) {
919
+ request.logger.error({ msg: 'Failed to test prompt', err });
920
+ return { success: false, error: err.message };
921
+ }
922
+ },
923
+ options: {
924
+ payload: {
925
+ maxBytes: MAX_BODY_SIZE,
926
+ timeout: MAX_PAYLOAD_TIMEOUT
927
+ },
928
+ validate: {
929
+ options: {
930
+ stripUnknown: true,
931
+ abortEarly: false,
932
+ convert: true
933
+ },
934
+
935
+ failAction,
936
+
937
+ payload: Joi.object({
938
+ emailFile: Joi.string().base64({ paddingRequired: false }).required(),
939
+ openAiAPIKey: settingsSchema.openAiAPIKey.empty(''),
940
+ openAiModel: settingsSchema.openAiModel.empty(''),
941
+ openAiAPIUrl: settingsSchema.openAiAPIUrl.default(''),
942
+ openAiPrompt: settingsSchema.openAiPrompt.default(''),
943
+ openAiTemperature: settingsSchema.openAiTemperature.empty(''),
944
+ openAiTopP: settingsSchema.openAiTopP.empty(''),
945
+ openAiMaxTokens: settingsSchema.openAiMaxTokens.empty('')
946
+ })
947
+ }
948
+ }
949
+ });
950
+
951
+ server.route({
952
+ method: 'POST',
953
+ path: '/admin/config/ai/reload-models',
954
+ async handler(request) {
955
+ try {
956
+ request.logger.info({ msg: 'Reload models' });
957
+
958
+ const { models } = await call({
959
+ cmd: 'openAiListModels',
960
+ data: {
961
+ openAiAPIKey: request.payload.openAiAPIKey,
962
+ openAiAPIUrl: request.payload.openAiAPIUrl
963
+ },
964
+ timeout: 2 * 60 * 1000
965
+ });
966
+
967
+ if (models && models.length) {
968
+ await settings.set('openAiModels', models);
969
+ }
970
+
971
+ return { success: true, models };
972
+ } catch (err) {
973
+ request.logger.error({ msg: 'Failed reloading OpenAI models', err });
974
+ return {
975
+ success: false,
976
+ error: err.message
977
+ };
978
+ }
979
+ },
980
+ options: {
981
+ validate: {
982
+ options: {
983
+ stripUnknown: true,
984
+ abortEarly: false,
985
+ convert: true
986
+ },
987
+
988
+ failAction,
989
+
990
+ payload: Joi.object({
991
+ openAiAPIKey: settingsSchema.openAiAPIKey.empty(''),
992
+ openAiAPIUrl: settingsSchema.openAiAPIUrl.default('')
993
+ })
994
+ }
995
+ }
996
+ });
997
+
998
+ server.route({
999
+ method: 'POST',
1000
+ path: '/admin/config/service/preview',
1001
+ async handler(request, h) {
1002
+ return h.view(
1003
+ 'config/service-preview',
1004
+ {
1005
+ pageBrandName: request.payload.pageBrandName,
1006
+ embeddedTemplateHeader: request.payload.templateHeader,
1007
+ embeddedTemplateHtmlHead: request.payload.templateHtmlHead
1008
+ },
1009
+ {
1010
+ layout: 'public'
1011
+ }
1012
+ );
1013
+ },
1014
+ options: {
1015
+ validate: {
1016
+ options: {
1017
+ stripUnknown: true,
1018
+ abortEarly: false,
1019
+ convert: true
1020
+ },
1021
+
1022
+ async failAction(request, h, err) {
1023
+ request.logger.error({ msg: 'Failed to process preview', err });
1024
+ return h.redirect('/admin').takeover();
1025
+ },
1026
+
1027
+ payload: Joi.object({
1028
+ pageBrandName: settingsSchema.pageBrandName.default(''),
1029
+ templateHeader: settingsSchema.templateHeader.default(''),
1030
+ templateHtmlHead: settingsSchema.templateHtmlHead.default('')
1031
+ })
1032
+ }
1033
+ }
1034
+ });
1035
+
1036
+ server.route({
1037
+ method: 'POST',
1038
+ path: '/admin/config/clear-error',
1039
+ async handler(request) {
1040
+ switch (request.payload.alert) {
1041
+ case 'open-ai':
1042
+ await redis.del(`${REDIS_PREFIX}:openai:error`);
1043
+ break;
1044
+
1045
+ case 'webhook-default':
1046
+ await settings.clear('webhookErrorFlag');
1047
+ break;
1048
+
1049
+ case 'webhook-route':
1050
+ if (request.payload.entry) {
1051
+ await redis.hdel(`${REDIS_PREFIX}wh:c`, `${request.payload.entry}:webhookErrorFlag`);
1052
+ }
1053
+ break;
1054
+ }
1055
+
1056
+ return { success: true };
1057
+ },
1058
+ options: {
1059
+ validate: {
1060
+ options: {
1061
+ stripUnknown: true,
1062
+ abortEarly: false,
1063
+ convert: true
1064
+ },
1065
+
1066
+ failAction,
1067
+
1068
+ payload: Joi.object({
1069
+ alert: Joi.string().required().max(1024),
1070
+ entry: Joi.string().empty('').max(1024).trim()
1071
+ })
1072
+ }
1073
+ }
1074
+ });
1075
+
1076
+ server.route({
1077
+ method: 'POST',
1078
+ path: '/admin/config/service/clean',
1079
+ async handler(request) {
1080
+ let errors = [];
1081
+
1082
+ for (let queue of [submitQueue, notifyQueue, documentsQueue]) {
1083
+ for (let type of ['failed', 'completed']) {
1084
+ try {
1085
+ await queue.clean(1000, 100000, type);
1086
+ request.logger.trace({ msg: 'Queue cleaned', queue: queue.name, type });
1087
+ } catch (err) {
1088
+ request.logger.error({ msg: 'Failed to clean queue', queue: queue.name, type, err });
1089
+ errors.push(err.message);
1090
+ }
1091
+ }
1092
+ }
1093
+
1094
+ if (errors.length) {
1095
+ return { success: false, error: 'Cleaning failed for some queues' };
1096
+ }
1097
+
1098
+ return { success: true };
1099
+ },
1100
+ options: {
1101
+ validate: {
1102
+ options: {
1103
+ stripUnknown: true,
1104
+ abortEarly: false,
1105
+ convert: true
1106
+ }
1107
+ }
1108
+ }
1109
+ });
1110
+
1111
+ server.route({
1112
+ method: 'GET',
1113
+ path: '/admin/config/logging',
1114
+ async handler(request, h) {
1115
+ let values = (await settings.get('logs')) || {};
1116
+
1117
+ if (typeof values.maxLogLines === 'undefined') {
1118
+ values.maxLogLines = DEFAULT_MAX_LOG_LINES;
1119
+ }
1120
+
1121
+ values.accounts = [].concat(values.accounts || []).join('\n');
1122
+
1123
+ let sentryValues = await settings.getMulti('sentryEnabled', 'sentryDsn');
1124
+ values.sentryEnabled = !!sentryValues.sentryEnabled;
1125
+ values.sentryDsn = sentryValues.sentryDsn || '';
1126
+
1127
+ return h.view(
1128
+ 'config/logging',
1129
+ {
1130
+ pageTitle: 'Logging',
1131
+ menuConfig: true,
1132
+ menuConfigLogging: true,
1133
+
1134
+ sentryEnvManaged: !!readEnvValue('SENTRY_DSN'),
1135
+
1136
+ values
1137
+ },
1138
+ {
1139
+ layout: 'app'
1140
+ }
1141
+ );
1142
+ }
1143
+ });
1144
+
1145
+ server.route({
1146
+ method: 'POST',
1147
+ path: '/admin/config/logging',
1148
+ async handler(request, h) {
1149
+ try {
1150
+ const data = {
1151
+ logs: {
670
1152
  all: !!request.payload.all,
671
1153
  maxLogLines: request.payload.maxLogLines || 0
672
1154
  }
673
1155
  };
674
1156
 
1157
+ if (!readEnvValue('SENTRY_DSN')) {
1158
+ // the form renders these fields as disabled when the DSN is pinned by the
1159
+ // environment, so do not overwrite the stored values in that case
1160
+ data.sentryEnabled = !!request.payload.sentryEnabled;
1161
+ data.sentryDsn = request.payload.sentryDsn || '';
1162
+ }
1163
+
675
1164
  for (let key of Object.keys(data)) {
676
1165
  await settings.set(key, data[key]);
677
1166
  }
678
1167
 
679
1168
  await request.flash({ type: 'info', message: `Configuration updated` });
1169
+
680
1170
  return h.redirect('/admin/config/logging');
681
1171
  } catch (err) {
682
1172
  await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
@@ -689,15 +1179,23 @@ function init(args) {
689
1179
  menuConfig: true,
690
1180
  menuConfigWebhooks: true
691
1181
  },
692
- { layout: 'app' }
1182
+ {
1183
+ layout: 'app'
1184
+ }
693
1185
  );
694
1186
  }
695
1187
  },
696
1188
  options: {
697
1189
  validate: {
698
- options: { stripUnknown: true, abortEarly: false, convert: true },
1190
+ options: {
1191
+ stripUnknown: true,
1192
+ abortEarly: false,
1193
+ convert: true
1194
+ },
1195
+
699
1196
  async failAction(request, h, err) {
700
1197
  let errors = {};
1198
+
701
1199
  if (err.details) {
702
1200
  err.details.forEach(detail => {
703
1201
  if (!errors[detail.path]) {
@@ -716,18 +1214,21 @@ function init(args) {
716
1214
  pageTitle: 'Logging',
717
1215
  menuConfig: true,
718
1216
  menuConfigWebhooks: true,
1217
+
719
1218
  errors
720
1219
  },
721
- { layout: 'app' }
1220
+ {
1221
+ layout: 'app'
1222
+ }
722
1223
  )
723
1224
  .takeover();
724
1225
  },
1226
+
725
1227
  payload: Joi.object(configLoggingSchema)
726
1228
  }
727
1229
  }
728
1230
  });
729
1231
 
730
- // Logging reconnect route
731
1232
  server.route({
732
1233
  method: 'POST',
733
1234
  path: '/admin/config/logging/reconnect',
@@ -744,7 +1245,10 @@ function init(args) {
744
1245
  }
745
1246
  }
746
1247
 
747
- return { success: true, accounts: requested };
1248
+ return {
1249
+ success: true,
1250
+ accounts: requested
1251
+ };
748
1252
  } catch (err) {
749
1253
  request.logger.error({ msg: 'Failed to request reconnect', err, accounts: request.payload.accounts });
750
1254
  return { success: false, error: err.message };
@@ -752,16 +1256,26 @@ function init(args) {
752
1256
  },
753
1257
  options: {
754
1258
  validate: {
755
- options: { stripUnknown: true, abortEarly: false, convert: true },
1259
+ options: {
1260
+ stripUnknown: true,
1261
+ abortEarly: false,
1262
+ convert: true
1263
+ },
1264
+
756
1265
  failAction,
1266
+
757
1267
  payload: Joi.object({
758
- accounts: Joi.array().items(Joi.string().max(256)).default([]).label('LoggedAccounts')
1268
+ accounts: Joi.array()
1269
+ .items(Joi.string().max(256))
1270
+ .default([])
1271
+ .example(['account-id-1', 'account-id-2'])
1272
+ .description('Request reconnect for listed accounts')
1273
+ .label('LoggedAccounts')
759
1274
  })
760
1275
  }
761
1276
  }
762
1277
  });
763
1278
 
764
- // Webhooks test route
765
1279
  server.route({
766
1280
  method: 'POST',
767
1281
  path: '/admin/config/webhooks/test',
@@ -773,7 +1287,11 @@ function init(args) {
773
1287
 
774
1288
  const webhooks = request.payload.webhooks;
775
1289
  if (!webhooks) {
776
- return { success: false, target: webhooks, error: 'Webhook URL is not set' };
1290
+ return {
1291
+ success: false,
1292
+ target: webhooks,
1293
+ error: 'Webhook URL is not set'
1294
+ };
777
1295
  }
778
1296
 
779
1297
  let parsed = new URL(webhooks);
@@ -800,9 +1318,15 @@ function init(args) {
800
1318
  .map(line => {
801
1319
  let sep = line.indexOf(':');
802
1320
  if (sep >= 0) {
803
- return { key: line.substring(0, sep).trim(), value: line.substring(sep + 1).trim() };
1321
+ return {
1322
+ key: line.substring(0, sep).trim(),
1323
+ value: line.substring(sep + 1).trim()
1324
+ };
804
1325
  }
805
- return { key: line, value: '' };
1326
+ return {
1327
+ key: line,
1328
+ value: ''
1329
+ };
806
1330
  });
807
1331
 
808
1332
  customHeaders.forEach(header => {
@@ -813,6 +1337,7 @@ function init(args) {
813
1337
  let duration;
814
1338
  try {
815
1339
  let res;
1340
+
816
1341
  let serviceUrl = await settings.get('serviceUrl');
817
1342
 
818
1343
  try {
@@ -825,7 +1350,9 @@ function init(args) {
825
1350
  account: null,
826
1351
  date: new Date().toISOString(),
827
1352
  event: 'test',
828
- data: { nonce: crypto.randomBytes(NONCE_BYTES).toString('base64url') }
1353
+ data: {
1354
+ nonce: crypto.randomBytes(NONCE_BYTES).toString('base64url')
1355
+ }
829
1356
  }),
830
1357
  headers,
831
1358
  dispatcher: httpAgent.retry
@@ -842,194 +1369,151 @@ function init(args) {
842
1369
  throw err;
843
1370
  }
844
1371
 
845
- return { success: true, target: webhooks, duration };
1372
+ return {
1373
+ success: true,
1374
+ target: webhooks,
1375
+ duration
1376
+ };
846
1377
  } catch (err) {
847
1378
  request.logger.error({ msg: 'Failed posting webhook', webhooks, event: 'test', err });
848
- return { success: false, target: webhooks, duration, error: err.message, code: err.code };
1379
+ return {
1380
+ success: false,
1381
+ target: webhooks,
1382
+ duration,
1383
+ error: err.message,
1384
+ code: err.code
1385
+ };
849
1386
  }
850
1387
  },
851
1388
  options: {
852
1389
  tags: ['test'],
853
1390
  validate: {
854
- options: { stripUnknown: true, abortEarly: false, convert: true },
1391
+ options: {
1392
+ stripUnknown: true,
1393
+ abortEarly: false,
1394
+ convert: true
1395
+ },
1396
+
855
1397
  failAction,
1398
+
856
1399
  payload: Joi.object({
857
1400
  webhooks: Joi.string()
858
- .uri({ scheme: ['http', 'https'], allowRelative: false })
859
- .allow(''),
1401
+ .uri({
1402
+ scheme: ['http', 'https'],
1403
+ allowRelative: false
1404
+ })
1405
+ .allow('')
1406
+ .example('https://myservice.com/imap/webhooks')
1407
+ .description('Webhook URL'),
860
1408
  customHeaders: Joi.string()
861
1409
  .allow('')
862
1410
  .trim()
863
1411
  .max(10 * 1024)
864
- .default(''),
1412
+ .default('')
1413
+ .description('Custom request headers'),
865
1414
  payload: Joi.string()
866
1415
  .max(1024 * 1024)
867
1416
  .empty('')
868
1417
  .trim()
1418
+ .description('Example JSON payload')
869
1419
  })
870
1420
  }
871
1421
  }
872
1422
  });
873
1423
 
874
- // AI config routes
875
- server.route({
876
- method: 'GET',
877
- path: '/admin/config/ai',
878
- async handler(request, h) {
879
- const errorLog = ((await llmPreProcess.getErrorLog()) || []).map(entry => {
880
- if (entry.error && typeof entry.error === 'string') {
881
- entry.error = entry.error
882
- .replace(/\r?\n/g, '\n')
883
- .replace(/^\s+at\s+.*$/gm, '')
884
- .replace(/\n+/g, '\n')
885
- .trim()
886
- .replace(/(evalmachine.<anonymous>:)(\d+)/, (o, p, n) => p + (Number(n) - 1));
887
- }
888
- return entry;
889
- });
890
-
891
- const values = {
892
- generateEmailSummary: (await settings.get('generateEmailSummary')) || false,
893
- openAiPrompt: ((await settings.get('openAiPrompt')) || '').toString(),
894
- contentFnJson: JSON.stringify(
895
- ((await settings.get(`openAiPreProcessingFn`)) || '').toString() ||
896
- `// Pass all emails
897
- return true;`
898
- ),
899
- openAiAPIUrl: ((await settings.get('openAiAPIUrl')) || '').toString(),
900
- openAiTemperature: ((await settings.get('openAiTemperature')) || '').toString(),
901
- openAiTopP: ((await settings.get('openAiTopP')) || '').toString(),
902
- openAiMaxTokens: ((await settings.get('openAiMaxTokens')) || '').toString()
903
- };
904
-
905
- if (!values.openAiPrompt.trim()) {
906
- values.openAiPrompt = await getDefaultPrompt();
907
- }
908
-
909
- let hasOpenAiAPIKey = !!(await settings.get('openAiAPIKey'));
910
- let openAiModel = await settings.get('openAiModel');
911
- let openAiError = await getOpenAiError(request.app.gt);
1424
+ // Webhook, template, gateway, and token routes are in admin-entities-routes.js
912
1425
 
913
- return h.view(
914
- 'config/ai',
915
- {
916
- pageTitle: 'AI Processing',
917
- menuConfig: true,
918
- menuConfigAi: true,
919
- errorLog,
920
- defaultPromptJson: JSON.stringify({ prompt: await getDefaultPrompt() }),
921
- values,
922
- hasOpenAiAPIKey,
923
- openAiError,
924
- openAiModels: await getOpenAiModels(OPEN_AI_MODELS, openAiModel),
925
- scriptEnvJson: JSON.stringify((await settings.get('scriptEnv')) || '{}'),
926
- examplePayloadsJson: JSON.stringify(
927
- (await getExampleDocumentsPayloads()).map(entry =>
928
- Object.assign({}, entry, { summary: undefined, riskAssessment: undefined, preview: undefined })
929
- )
930
- )
1426
+ server.route({
1427
+ method: 'GET',
1428
+ path: '/admin/config/license',
1429
+ async handler(request, h) {
1430
+ await call({ cmd: 'checkLicense' });
1431
+
1432
+ let subexp = await settings.get('subexp');
1433
+ let expiresDays;
1434
+ if (subexp && !(request.app.licenseInfo && request.app.licenseInfo.details && request.app.licenseInfo.details.lt)) {
1435
+ let delayMs = new Date(subexp) - Date.now();
1436
+ expiresDays = Math.max(Math.ceil(delayMs / (24 * 3600 * 1000)), 0);
1437
+ }
1438
+
1439
+ return h.view(
1440
+ 'config/license',
1441
+ {
1442
+ pageTitle: 'License',
1443
+ menuLicense: true,
1444
+ hideLicenseWarning: true,
1445
+ menuConfig: true,
1446
+ menuConfigLicense: true,
1447
+
1448
+ subexp,
1449
+ expiresDays,
1450
+
1451
+ showLicenseText:
1452
+ !request.app.licenseInfo ||
1453
+ !request.app.licenseInfo.active ||
1454
+ (request.app.licenseInfo.details && request.app.licenseInfo.details.trial)
931
1455
  },
932
- { layout: 'app' }
1456
+ {
1457
+ layout: 'app'
1458
+ }
933
1459
  );
934
1460
  }
935
1461
  });
936
1462
 
937
1463
  server.route({
938
1464
  method: 'POST',
939
- path: '/admin/config/ai',
1465
+ path: '/admin/config/license',
940
1466
  async handler(request, h) {
941
1467
  try {
942
- let contentFn;
943
- try {
944
- if (request.payload.contentFnJson === '') {
945
- contentFn = null;
946
- } else {
947
- contentFn = JSON.parse(request.payload.contentFnJson);
948
- if (typeof contentFn !== 'string') {
949
- throw new Error('Invalid Format');
950
- }
951
- }
952
- } catch (err) {
953
- err.details = { contentFnJson: 'Invalid JSON' };
1468
+ // update license
1469
+ const licenseInfo = await call({ cmd: 'updateLicense', license: request.payload.license });
1470
+ if (!licenseInfo) {
1471
+ let err = new Error('Failed to update license. Check license file contents.');
1472
+ err.statusCode = 403;
1473
+ err.details = { license: err.message };
954
1474
  throw err;
955
1475
  }
956
1476
 
957
- let data = {
958
- generateEmailSummary: request.payload.generateEmailSummary,
959
- openAiModel: request.payload.openAiModel,
960
- openAiAPIUrl: request.payload.openAiAPIUrl,
961
- openAiPrompt: (request.payload.openAiPrompt || '').toString(),
962
- openAiPreProcessingFn: contentFn,
963
- openAiTemperature: request.payload.openAiTemperature,
964
- openAiTopP: request.payload.openAiTopP,
965
- openAiMaxTokens: request.payload.openAiMaxTokens
966
- };
967
-
968
- let defaultUserPrompt = await getDefaultPrompt();
969
- if (!data.openAiPrompt.trim() || data.openAiPrompt.trim() === defaultUserPrompt.trim()) {
970
- data.openAiPrompt = '';
971
- }
972
-
973
- if (typeof request.payload.openAiAPIKey === 'string') {
974
- data.openAiAPIKey = request.payload.openAiAPIKey;
975
- }
976
-
977
- if (typeof request.payload.openAiAPIUrl === 'string') {
978
- data.openAiAPIUrl = request.payload.openAiAPIUrl;
979
- }
980
-
981
- for (let key of Object.keys(data)) {
982
- await settings.set(key, data[key]);
1477
+ if (licenseInfo.active) {
1478
+ await request.flash({ type: 'info', message: `License activated` });
983
1479
  }
984
1480
 
985
- await request.flash({ type: 'info', message: `Configuration updated` });
986
- return h.redirect('/admin/config/ai');
1481
+ return h.redirect('/admin/config/license');
987
1482
  } catch (err) {
988
- await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
989
- request.logger.error({ msg: 'Failed to update configuration', err });
990
-
991
- let hasOpenAiAPIKey = !!(await settings.get('openAiAPIKey'));
992
- let openAiError = await getOpenAiError(request.app.gt);
993
-
994
- const errorLog = ((await llmPreProcess.getErrorLog()) || []).map(entry => {
995
- if (entry.error && typeof entry.error === 'string') {
996
- entry.error = entry.error
997
- .replace(/\r?\n/g, '\n')
998
- .replace(/^\s+at\s+.*$/gm, '')
999
- .replace(/\n+/g, '\n')
1000
- .trim()
1001
- .replace(/(evalmachine.<anonymous>:)(\d+)/, (o, p, n) => p + (Number(n) - 1));
1002
- }
1003
- return entry;
1004
- });
1483
+ await request.flash({ type: 'danger', message: `Couldn't register license. Check the key and try again.` });
1484
+ request.logger.error({ msg: 'Failed to register license key', err });
1005
1485
 
1006
1486
  return h.view(
1007
- 'config/ai',
1487
+ 'config/license',
1008
1488
  {
1009
- pageTitle: 'AI Processing',
1489
+ pageTitle: 'License',
1010
1490
  menuConfig: true,
1011
- menuConfigAi: true,
1012
- errorLog,
1013
- defaultPromptJson: JSON.stringify({ prompt: await getDefaultPrompt() }),
1014
- hasOpenAiAPIKey,
1015
- openAiError,
1016
- openAiModels: await getOpenAiModels(OPEN_AI_MODELS, request.payload.openAiModel),
1017
- scriptEnvJson: JSON.stringify((await settings.get('scriptEnv')) || '{}'),
1018
- examplePayloadsJson: JSON.stringify(
1019
- (await getExampleDocumentsPayloads()).map(entry =>
1020
- Object.assign({}, entry, { summary: undefined, riskAssessment: undefined, preview: undefined })
1021
- )
1022
- )
1491
+ menuConfigWebhooks: true,
1492
+
1493
+ errors: err.details,
1494
+ showLicenseText:
1495
+ (err.details && !!err.details.license) ||
1496
+ !request.app.licenseInfo ||
1497
+ !request.app.licenseInfo.active ||
1498
+ (request.app.licenseInfo.details && request.app.licenseInfo.details.trial)
1023
1499
  },
1024
- { layout: 'app' }
1500
+ {
1501
+ layout: 'app'
1502
+ }
1025
1503
  );
1026
1504
  }
1027
1505
  },
1028
1506
  options: {
1029
1507
  validate: {
1030
- options: { stripUnknown: true, abortEarly: false, convert: true },
1508
+ options: {
1509
+ stripUnknown: true,
1510
+ abortEarly: false,
1511
+ convert: true
1512
+ },
1513
+
1031
1514
  async failAction(request, h, err) {
1032
1515
  let errors = {};
1516
+
1033
1517
  if (err.details) {
1034
1518
  err.details.forEach(detail => {
1035
1519
  if (!errors[detail.path]) {
@@ -1038,200 +1522,155 @@ return true;`
1038
1522
  });
1039
1523
  }
1040
1524
 
1041
- await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
1042
- request.logger.error({ msg: 'Failed to update configuration', err });
1043
-
1044
- let hasOpenAiAPIKey = !!(await settings.get('openAiAPIKey'));
1045
- let openAiError = await getOpenAiError(request.app.gt);
1046
-
1047
- const errorLog = ((await llmPreProcess.getErrorLog()) || []).map(entry => {
1048
- if (entry.error && typeof entry.error === 'string') {
1049
- entry.error = entry.error
1050
- .replace(/\r?\n/g, '\n')
1051
- .replace(/^\s+at\s+.*$/gm, '')
1052
- .replace(/\n+/g, '\n')
1053
- .trim()
1054
- .replace(/(evalmachine.<anonymous>:)(\d+)/, (o, p, n) => p + (Number(n) - 1));
1055
- }
1056
- return entry;
1057
- });
1525
+ await request.flash({ type: 'danger', message: `Couldn't register license. Check the key and try again.` });
1526
+ request.logger.error({ msg: 'Failed to register license key', err });
1058
1527
 
1059
1528
  return h
1060
1529
  .view(
1061
- 'config/ai',
1530
+ 'config/license',
1062
1531
  {
1063
- pageTitle: 'AI Processing',
1532
+ pageTitle: 'License',
1064
1533
  menuConfig: true,
1065
- menuConfigAi: true,
1066
- errorLog,
1067
- defaultPromptJson: JSON.stringify({ prompt: await getDefaultPrompt() }),
1068
- hasOpenAiAPIKey,
1069
- openAiError,
1070
- openAiModels: await getOpenAiModels(OPEN_AI_MODELS, request.payload.openAiModel),
1071
- scriptEnvJson: JSON.stringify((await settings.get('scriptEnv')) || '{}'),
1072
- examplePayloadsJson: JSON.stringify(
1073
- (await getExampleDocumentsPayloads()).map(entry =>
1074
- Object.assign({}, entry, { summary: undefined, riskAssessment: undefined, preview: undefined })
1075
- )
1076
- ),
1077
- errors
1534
+ menuConfigWebhooks: true,
1535
+
1536
+ errors,
1537
+
1538
+ showLicenseText:
1539
+ (errors && !!errors.license) ||
1540
+ !request.app.licenseInfo ||
1541
+ !request.app.licenseInfo.active ||
1542
+ (request.app.licenseInfo.details && request.app.licenseInfo.details.trial)
1078
1543
  },
1079
- { layout: 'app' }
1544
+ {
1545
+ layout: 'app'
1546
+ }
1080
1547
  )
1081
1548
  .takeover();
1082
1549
  },
1550
+
1083
1551
  payload: Joi.object({
1084
- generateEmailSummary: settingsSchema.generateEmailSummary.default(false),
1085
- openAiAPIKey: settingsSchema.openAiAPIKey.empty(''),
1086
- openAiModel: settingsSchema.openAiModel.empty(''),
1087
- openAiAPIUrl: settingsSchema.openAiAPIUrl.default(''),
1088
- openAiPrompt: settingsSchema.openAiPrompt.default(''),
1089
- contentFnJson: Joi.string()
1090
- .max(1024 * 1024)
1091
- .default('')
1092
- .allow('')
1093
- .trim(),
1094
- openAiTemperature: settingsSchema.openAiTemperature.default(''),
1095
- openAiTopP: settingsSchema.openAiTopP.default(''),
1096
- openAiMaxTokens: settingsSchema.openAiMaxTokens.default('')
1097
- })
1552
+ license: Joi.string()
1553
+ .max(10 * 1024)
1554
+ .required()
1555
+ .example('-----BEGIN LICENSE-----\r\n...')
1556
+ .description('License file')
1557
+ }).label('RegisterLicense')
1098
1558
  }
1099
1559
  }
1100
1560
  });
1101
1561
 
1102
- // AI test prompt route
1103
1562
  server.route({
1104
1563
  method: 'POST',
1105
- path: '/admin/config/ai/test-prompt',
1106
- async handler(request) {
1564
+ path: '/admin/config/license/delete',
1565
+ async handler(request, h) {
1107
1566
  try {
1108
- request.logger.info({ msg: 'Prompt test' });
1109
-
1110
- const parsed = await simpleParser(Buffer.from(request.payload.emailFile, 'base64'));
1111
-
1112
- const response = {};
1113
- response.summary = await call({
1114
- cmd: 'generateSummary',
1115
- data: {
1116
- message: {
1117
- headers: parsed.headerLines.map(header => libmime.decodeHeader(header.line)),
1118
- attachments: parsed.attachments,
1119
- html: parsed.html,
1120
- text: parsed.text
1121
- },
1122
- openAiAPIKey: request.payload.openAiAPIKey,
1123
- openAiModel: request.payload.openAiModel,
1124
- openAiAPIUrl: request.payload.openAiAPIUrl,
1125
- openAiPrompt: request.payload.openAiPrompt,
1126
- openAiTemperature: request.payload.openAiTemperature,
1127
- openAiTopP: request.payload.openAiTopP,
1128
- openAiMaxTokens: request.payload.openAiMaxTokens
1129
- },
1130
- timeout: 2 * 60 * 1000
1131
- });
1132
-
1133
- for (let key of Object.keys(response.summary)) {
1134
- if (key.charAt(0) === '_' || response.summary[key] === '') {
1135
- delete response.summary[key];
1136
- }
1137
- if (key === 'riskAssessment') {
1138
- response.riskAssessment = response.summary.riskAssessment;
1139
- delete response.summary.riskAssessment;
1140
- }
1567
+ const licenseInfo = await call({ cmd: 'removeLicense' });
1568
+ if (!licenseInfo) {
1569
+ let err = new Error('Failed to clear license info');
1570
+ err.statusCode = 403;
1571
+ throw err;
1572
+ } else {
1573
+ await request.flash({ type: 'info', message: `License removed` });
1141
1574
  }
1142
1575
 
1143
- return { success: true, response };
1576
+ return h.redirect('/admin/config/license');
1144
1577
  } catch (err) {
1145
- request.logger.error({ msg: 'Failed to test prompt', err });
1146
- return { success: false, error: err.message };
1578
+ await request.flash({ type: 'danger', message: `Couldn't remove license. Try again.` });
1579
+ request.logger.error({ msg: 'Failed to unregister license key', err, token: request.payload.token, remoteAddress: request.app.ip });
1580
+ return h.redirect('/admin/config/license');
1147
1581
  }
1148
1582
  },
1149
1583
  options: {
1150
- payload: { maxBytes: MAX_BODY_SIZE, timeout: MAX_PAYLOAD_TIMEOUT },
1151
1584
  validate: {
1152
- options: { stripUnknown: true, abortEarly: false, convert: true },
1153
- failAction,
1154
- payload: Joi.object({
1155
- emailFile: Joi.string().base64({ paddingRequired: false }).required(),
1156
- openAiAPIKey: settingsSchema.openAiAPIKey.empty(''),
1157
- openAiModel: settingsSchema.openAiModel.empty(''),
1158
- openAiAPIUrl: settingsSchema.openAiAPIUrl.default(''),
1159
- openAiPrompt: settingsSchema.openAiPrompt.default(''),
1160
- openAiTemperature: settingsSchema.openAiTemperature.empty(''),
1161
- openAiTopP: settingsSchema.openAiTopP.empty(''),
1162
- openAiMaxTokens: settingsSchema.openAiMaxTokens.empty('')
1163
- })
1585
+ options: {
1586
+ stripUnknown: true,
1587
+ abortEarly: false,
1588
+ convert: true
1589
+ },
1590
+
1591
+ async failAction(request, h, err) {
1592
+ await request.flash({ type: 'danger', message: `Couldn't remove license. Try again.` });
1593
+ request.logger.error({ msg: 'Failed to unregister license key', err });
1594
+
1595
+ return h.redirect('/admin/config/license').takeover();
1596
+ },
1597
+
1598
+ payload: Joi.object({})
1164
1599
  }
1165
1600
  }
1166
1601
  });
1167
1602
 
1168
- // AI reload models route
1169
1603
  server.route({
1170
1604
  method: 'POST',
1171
- path: '/admin/config/ai/reload-models',
1605
+ path: '/admin/config/license/trial',
1172
1606
  async handler(request) {
1173
1607
  try {
1174
- request.logger.info({ msg: 'Reload models' });
1608
+ // provision new trial license
1175
1609
 
1176
- const { models } = await call({
1177
- cmd: 'openAiListModels',
1178
- data: {
1179
- openAiAPIKey: request.payload.openAiAPIKey,
1180
- openAiAPIUrl: request.payload.openAiAPIUrl
1181
- },
1182
- timeout: 2 * 60 * 1000
1610
+ let headers = {
1611
+ 'Content-Type': 'application/json',
1612
+ 'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
1613
+ };
1614
+
1615
+ let res = await fetchCmd(`${LICENSE_HOST}/licenses/trial`, {
1616
+ method: 'post',
1617
+ body: JSON.stringify({
1618
+ version: packageData.version,
1619
+ app: '@postalsys/emailengine-app',
1620
+ hostname: os.hostname() || 'localhost',
1621
+ url: (await settings.get('serviceUrl')) || ''
1622
+ }),
1623
+ headers,
1624
+ dispatcher: httpAgent.retry
1183
1625
  });
1184
1626
 
1185
- if (models && models.length) {
1186
- await settings.set('openAiModels', models);
1627
+ if (!res.ok) {
1628
+ let err = new Error(res.statusText || `Invalid response: ${res.status} ${res.statusText}`);
1629
+ err.statusCode = res.status;
1630
+
1631
+ try {
1632
+ err.response = await res.json();
1633
+ } catch (err) {
1634
+ // ignore
1635
+ }
1636
+
1637
+ throw err;
1187
1638
  }
1188
1639
 
1189
- return { success: true, models };
1190
- } catch (err) {
1191
- request.logger.error({ msg: 'Failed reloading OpenAI models', err });
1192
- return { success: false, error: err.message };
1193
- }
1194
- },
1195
- options: {
1196
- validate: {
1197
- options: { stripUnknown: true, abortEarly: false, convert: true },
1198
- failAction,
1199
- payload: Joi.object({
1200
- openAiAPIKey: settingsSchema.openAiAPIKey.empty(''),
1201
- openAiAPIUrl: settingsSchema.openAiAPIUrl.default('')
1202
- })
1203
- }
1204
- }
1205
- });
1640
+ const data = await res.json();
1206
1641
 
1207
- // Browser config route
1208
- server.route({
1209
- method: 'POST',
1210
- path: '/admin/config/browser',
1211
- async handler(request) {
1212
- for (let key of ['serviceUrl', 'language', 'timezone']) {
1213
- if (request.payload[key]) {
1214
- let existingValue = await settings.get(key);
1215
- if (existingValue === null) {
1216
- await settings.set(key, request.payload[key]);
1217
- }
1642
+ let licenseFile = `-----BEGIN LICENSE-----
1643
+ ${Buffer.from(data.content, 'base64url').toString('base64')}
1644
+ -----END LICENSE-----`;
1645
+
1646
+ const licenseInfo = await call({ cmd: 'updateLicense', license: licenseFile });
1647
+ if (!licenseInfo) {
1648
+ let err = new Error('Failed to update license. Check license file contents.');
1649
+ err.statusCode = 403;
1650
+ err.details = { license: err.message };
1651
+ throw err;
1652
+ }
1653
+
1654
+ if (licenseInfo.active) {
1655
+ await request.flash({ type: 'info', message: `Trial activated` });
1656
+ return { success: true, message: `Trial activated` };
1218
1657
  }
1658
+
1659
+ throw new Error('Failed to activate provisioned trial license');
1660
+ } catch (err) {
1661
+ request.logger.error({ msg: 'Failed to provision a trial license key', err, remoteAddress: request.app.ip });
1662
+ return { success: false, error: (err.response && err.response.error) || err.message };
1219
1663
  }
1220
- return { success: true };
1221
1664
  },
1222
1665
  options: {
1223
1666
  validate: {
1224
- options: { stripUnknown: true, abortEarly: false, convert: true },
1225
- failAction,
1226
- payload: Joi.object({
1227
- serviceUrl: settingsSchema.serviceUrl.empty('').allow(false),
1228
- language: Joi.string()
1229
- .empty('')
1230
- .lowercase()
1231
- .regex(/^[a-z0-9]{1,5}([-_][a-z0-9]{1,15})?$/)
1232
- .allow(false),
1233
- timezone: Joi.string().empty('').allow(false).max(255)
1234
- })
1667
+ options: {
1668
+ stripUnknown: true,
1669
+ abortEarly: false,
1670
+ convert: true
1671
+ },
1672
+
1673
+ failAction
1235
1674
  }
1236
1675
  }
1237
1676
  });