emailengine-app 2.68.0 → 2.69.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 (74) hide show
  1. package/.github/codeql/codeql-config.yml +16 -0
  2. package/.github/workflows/codeql.yml +102 -0
  3. package/.github/workflows/deploy.yml +8 -0
  4. package/.github/workflows/release.yaml +4 -0
  5. package/.github/workflows/test.yml +3 -0
  6. package/CHANGELOG.md +49 -0
  7. package/SECURITY.md +80 -0
  8. package/SECURITY.txt +27 -0
  9. package/config/default.toml +2 -0
  10. package/data/google-crawlers.json +13 -1
  11. package/lib/account.js +62 -25
  12. package/lib/api-routes/account-routes.js +493 -75
  13. package/lib/api-routes/blocklist-routes.js +337 -0
  14. package/lib/api-routes/delivery-test-routes.js +321 -0
  15. package/lib/api-routes/export-routes.js +1 -12
  16. package/lib/api-routes/gateway-routes.js +376 -0
  17. package/lib/api-routes/license-routes.js +142 -0
  18. package/lib/api-routes/mailbox-routes.js +318 -0
  19. package/lib/api-routes/message-routes.js +21 -129
  20. package/lib/api-routes/oauth2-app-routes.js +631 -0
  21. package/lib/api-routes/outbox-routes.js +173 -0
  22. package/lib/api-routes/pubsub-routes.js +98 -0
  23. package/lib/api-routes/route-helpers.js +45 -0
  24. package/lib/api-routes/settings-routes.js +331 -0
  25. package/lib/api-routes/stats-routes.js +77 -0
  26. package/lib/api-routes/submit-routes.js +472 -0
  27. package/lib/api-routes/template-routes.js +7 -55
  28. package/lib/api-routes/token-routes.js +297 -0
  29. package/lib/api-routes/webhook-route-routes.js +152 -0
  30. package/lib/email-client/gmail-client.js +14 -0
  31. package/lib/email-client/imap/mailbox.js +34 -11
  32. package/lib/email-client/imap/subconnection.js +20 -12
  33. package/lib/email-client/imap/sync-operations.js +130 -2
  34. package/lib/email-client/imap-client.js +116 -58
  35. package/lib/email-client/outlook-client.js +85 -13
  36. package/lib/export.js +60 -19
  37. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  38. package/lib/imapproxy/imap-core/lib/imap-command.js +7 -2
  39. package/lib/imapproxy/imap-core/lib/imap-connection.js +113 -23
  40. package/lib/imapproxy/imap-core/lib/imap-server.js +25 -1
  41. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  42. package/lib/imapproxy/imap-server.js +92 -29
  43. package/lib/message-port-stream.js +113 -16
  44. package/lib/reject-worker-calls.js +42 -0
  45. package/lib/routes-ui.js +37 -8778
  46. package/lib/schemas.js +26 -1
  47. package/lib/tools.js +73 -0
  48. package/lib/ui-routes/account-routes.js +40 -210
  49. package/lib/ui-routes/admin-config-routes.js +913 -487
  50. package/lib/ui-routes/admin-entities-routes.js +1 -0
  51. package/lib/ui-routes/auth-routes.js +1339 -0
  52. package/lib/ui-routes/dashboard-routes.js +188 -0
  53. package/lib/ui-routes/document-store-routes.js +800 -0
  54. package/lib/ui-routes/export-routes.js +217 -0
  55. package/lib/ui-routes/internals-routes.js +354 -0
  56. package/lib/ui-routes/network-config-routes.js +759 -0
  57. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +371 -91
  58. package/lib/ui-routes/route-helpers.js +316 -0
  59. package/lib/ui-routes/smtp-test-routes.js +236 -0
  60. package/lib/ui-routes/unsubscribe-routes.js +234 -0
  61. package/lib/webhook-request.js +36 -0
  62. package/package.json +17 -17
  63. package/sbom.json +1 -1
  64. package/server.js +217 -19
  65. package/static/licenses.html +52 -182
  66. package/translations/messages.pot +131 -151
  67. package/views/dashboard.hbs +7 -26
  68. package/views/internals/index.hbs +15 -0
  69. package/views/tokens/index.hbs +9 -0
  70. package/workers/api.js +198 -4401
  71. package/workers/export.js +87 -54
  72. package/workers/imap.js +29 -13
  73. package/workers/submit.js +20 -11
  74. package/workers/webhooks.js +6 -20
@@ -1,34 +1,53 @@
1
1
  'use strict';
2
2
 
3
+ // NB! This file is processed by the gettext parser (npm run gettext) and can not use newer syntax like ?.
4
+
5
+ // Admin UI routes for the remaining /admin/config/* pages: webhooks, service URL/branding,
6
+ // AI (OpenAI) settings, logging, and license. Extracted verbatim from lib/routes-ui.js.
7
+ // The notificationTypes / configWebhooksSchema / configLoggingSchema consts, the
8
+ // getOpenAiError helper, and the getDefaultPrompt helper move with the routes (only these
9
+ // config pages use them).
10
+
3
11
  const Joi = require('joi');
4
12
  const crypto = require('crypto');
13
+ const config = require('@zone-eu/wild-config');
14
+ const libmime = require('libmime');
5
15
  const he = require('he');
16
+ const os = require('os');
17
+ const packageData = require('../../package.json');
6
18
  const { simpleParser } = require('mailparser');
7
- const libmime = require('libmime');
19
+ const { fetch: fetchCmd } = require('undici');
8
20
 
9
21
  const settings = require('../settings');
10
- const { redis, submitQueue, notifyQueue, documentsQueue } = require('../db');
22
+ const consts = require('../consts');
11
23
  const getSecret = require('../get-secret');
24
+ const timezonesList = require('timezones-list').default;
25
+ const { redis, submitQueue, notifyQueue, documentsQueue } = require('../db');
26
+ const { getByteSize, formatByteSize, getDuration, failAction, hasEnvValue, readEnvValue, httpAgent } = require('../tools');
12
27
  const { llmPreProcess } = require('../llm-pre-process');
13
28
  const { locales } = require('../translations');
14
- const consts = require('../consts');
15
- const packageData = require('../../package.json');
16
- const timezonesList = require('timezones-list').default;
29
+ const { settingsSchema } = require('../schemas');
30
+ const { getOpenAiModels, OPEN_AI_MODELS, getExampleDocumentsPayloads } = require('./route-helpers');
17
31
 
18
- const { failAction, getByteSize, formatByteSize, getDuration, readEnvValue, hasEnvValue, httpAgent } = require('../tools');
32
+ const { REDIS_PREFIX, DEFAULT_MAX_LOG_LINES, DEFAULT_DELIVERY_ATTEMPTS, DEFAULT_MAX_BODY_SIZE, DEFAULT_MAX_PAYLOAD_TIMEOUT, NONCE_BYTES } = consts;
19
33
 
20
- const { settingsSchema } = require('../schemas');
34
+ const LICENSE_HOST = 'https://postalsys.com';
21
35
 
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;
36
+ config.api = config.api || {
37
+ port: 3000,
38
+ host: '127.0.0.1',
39
+ proxy: false
40
+ };
24
41
 
25
- const { fetch: fetchCmd } = require('undici');
42
+ const MAX_BODY_SIZE = getByteSize(readEnvValue('EENGINE_MAX_BODY_SIZE') || config.api.maxBodySize) || DEFAULT_MAX_BODY_SIZE;
43
+ const MAX_PAYLOAD_TIMEOUT = getDuration(readEnvValue('EENGINE_MAX_PAYLOAD_TIMEOUT') || config.api.maxPayloadTimeout) || DEFAULT_MAX_PAYLOAD_TIMEOUT;
26
44
 
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
- ];
45
+ const ADMIN_ACCESS_ADDRESSES = hasEnvValue('EENGINE_ADMIN_ACCESS_ADDRESSES')
46
+ ? readEnvValue('EENGINE_ADMIN_ACCESS_ADDRESSES')
47
+ .split(',')
48
+ .map(v => v.trim())
49
+ .filter(v => v)
50
+ : null;
32
51
 
33
52
  const IMAP_INDEXERS = [
34
53
  {
@@ -55,30 +74,25 @@ const notificationTypes = Object.keys(consts)
55
74
  description: consts[`${key}_DESCRIPTION`]
56
75
  }));
57
76
 
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
77
  const configWebhooksSchema = {
70
- webhooksEnabled: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
78
+ webhooksEnabled: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false).description('Enable Webhooks'),
71
79
  webhooks: Joi.string()
72
- .uri({ scheme: ['http', 'https'], allowRelative: false })
80
+ .uri({
81
+ scheme: ['http', 'https'],
82
+ allowRelative: false
83
+ })
73
84
  .allow('')
74
- .example('https://myservice.com/imap/webhooks'),
85
+ .example('https://myservice.com/imap/webhooks')
86
+ .description('Webhook URL'),
75
87
  notifyAll: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
76
88
  headersAll: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
77
89
  notifyHeaders: Joi.string().empty('').trim(),
78
90
  notifyText: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
79
91
  notifyWebSafeHtml: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
92
+
80
93
  notifyTextSize: Joi.alternatives().try(
81
94
  Joi.number().empty('').integer().min(0),
95
+ // If it's a string, parse and convert it to bytes
82
96
  Joi.string().custom((value, helpers) => {
83
97
  let nr = getByteSize(value);
84
98
  if (typeof nr !== 'number' || nr < 0) {
@@ -87,11 +101,14 @@ const configWebhooksSchema = {
87
101
  return nr;
88
102
  }, 'Byte size conversion')
89
103
  ),
104
+
90
105
  notifyCalendarEvents: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
91
106
  inboxNewOnly: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
107
+
92
108
  notifyAttachments: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
93
109
  notifyAttachmentSize: Joi.alternatives().try(
94
110
  Joi.number().empty('').integer().min(0),
111
+ // If it's a string, parse and convert it to bytes
95
112
  Joi.string().custom((value, helpers) => {
96
113
  let nr = getByteSize(value);
97
114
  if (typeof nr !== 'number' || nr < 0) {
@@ -100,10 +117,12 @@ const configWebhooksSchema = {
100
117
  return nr;
101
118
  }, 'Byte size conversion')
102
119
  ),
120
+
103
121
  customHeaders: Joi.string()
104
122
  .allow('')
105
123
  .trim()
106
124
  .max(10 * 1024)
125
+ .description('Custom request headers')
107
126
  };
108
127
 
109
128
  for (let type of notificationTypes) {
@@ -111,47 +130,25 @@ for (let type of notificationTypes) {
111
130
  }
112
131
 
113
132
  const configLoggingSchema = {
114
- all: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
133
+ all: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false).description('Enable logs for all accounts'),
115
134
  maxLogLines: Joi.number().integer().empty('').min(0).max(10000000).default(DEFAULT_MAX_LOG_LINES)
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,143 +590,558 @@ 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 = {
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
+ return h.view(
1124
+ 'config/logging',
1125
+ {
1126
+ pageTitle: 'Logging',
1127
+ menuConfig: true,
1128
+ menuConfigLogging: true,
1129
+
1130
+ values
1131
+ },
1132
+ {
1133
+ layout: 'app'
1134
+ }
1135
+ );
1136
+ }
1137
+ });
1138
+
1139
+ server.route({
1140
+ method: 'POST',
1141
+ path: '/admin/config/logging',
1142
+ async handler(request, h) {
1143
+ try {
1144
+ const data = {
669
1145
  logs: {
670
1146
  all: !!request.payload.all,
671
1147
  maxLogLines: request.payload.maxLogLines || 0
@@ -677,6 +1153,7 @@ function init(args) {
677
1153
  }
678
1154
 
679
1155
  await request.flash({ type: 'info', message: `Configuration updated` });
1156
+
680
1157
  return h.redirect('/admin/config/logging');
681
1158
  } catch (err) {
682
1159
  await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
@@ -689,15 +1166,23 @@ function init(args) {
689
1166
  menuConfig: true,
690
1167
  menuConfigWebhooks: true
691
1168
  },
692
- { layout: 'app' }
1169
+ {
1170
+ layout: 'app'
1171
+ }
693
1172
  );
694
1173
  }
695
1174
  },
696
1175
  options: {
697
1176
  validate: {
698
- options: { stripUnknown: true, abortEarly: false, convert: true },
1177
+ options: {
1178
+ stripUnknown: true,
1179
+ abortEarly: false,
1180
+ convert: true
1181
+ },
1182
+
699
1183
  async failAction(request, h, err) {
700
1184
  let errors = {};
1185
+
701
1186
  if (err.details) {
702
1187
  err.details.forEach(detail => {
703
1188
  if (!errors[detail.path]) {
@@ -716,18 +1201,21 @@ function init(args) {
716
1201
  pageTitle: 'Logging',
717
1202
  menuConfig: true,
718
1203
  menuConfigWebhooks: true,
1204
+
719
1205
  errors
720
1206
  },
721
- { layout: 'app' }
1207
+ {
1208
+ layout: 'app'
1209
+ }
722
1210
  )
723
1211
  .takeover();
724
1212
  },
1213
+
725
1214
  payload: Joi.object(configLoggingSchema)
726
1215
  }
727
1216
  }
728
1217
  });
729
1218
 
730
- // Logging reconnect route
731
1219
  server.route({
732
1220
  method: 'POST',
733
1221
  path: '/admin/config/logging/reconnect',
@@ -744,7 +1232,10 @@ function init(args) {
744
1232
  }
745
1233
  }
746
1234
 
747
- return { success: true, accounts: requested };
1235
+ return {
1236
+ success: true,
1237
+ accounts: requested
1238
+ };
748
1239
  } catch (err) {
749
1240
  request.logger.error({ msg: 'Failed to request reconnect', err, accounts: request.payload.accounts });
750
1241
  return { success: false, error: err.message };
@@ -752,16 +1243,26 @@ function init(args) {
752
1243
  },
753
1244
  options: {
754
1245
  validate: {
755
- options: { stripUnknown: true, abortEarly: false, convert: true },
1246
+ options: {
1247
+ stripUnknown: true,
1248
+ abortEarly: false,
1249
+ convert: true
1250
+ },
1251
+
756
1252
  failAction,
1253
+
757
1254
  payload: Joi.object({
758
- accounts: Joi.array().items(Joi.string().max(256)).default([]).label('LoggedAccounts')
1255
+ accounts: Joi.array()
1256
+ .items(Joi.string().max(256))
1257
+ .default([])
1258
+ .example(['account-id-1', 'account-id-2'])
1259
+ .description('Request reconnect for listed accounts')
1260
+ .label('LoggedAccounts')
759
1261
  })
760
1262
  }
761
1263
  }
762
1264
  });
763
1265
 
764
- // Webhooks test route
765
1266
  server.route({
766
1267
  method: 'POST',
767
1268
  path: '/admin/config/webhooks/test',
@@ -773,7 +1274,11 @@ function init(args) {
773
1274
 
774
1275
  const webhooks = request.payload.webhooks;
775
1276
  if (!webhooks) {
776
- return { success: false, target: webhooks, error: 'Webhook URL is not set' };
1277
+ return {
1278
+ success: false,
1279
+ target: webhooks,
1280
+ error: 'Webhook URL is not set'
1281
+ };
777
1282
  }
778
1283
 
779
1284
  let parsed = new URL(webhooks);
@@ -800,9 +1305,15 @@ function init(args) {
800
1305
  .map(line => {
801
1306
  let sep = line.indexOf(':');
802
1307
  if (sep >= 0) {
803
- return { key: line.substring(0, sep).trim(), value: line.substring(sep + 1).trim() };
1308
+ return {
1309
+ key: line.substring(0, sep).trim(),
1310
+ value: line.substring(sep + 1).trim()
1311
+ };
804
1312
  }
805
- return { key: line, value: '' };
1313
+ return {
1314
+ key: line,
1315
+ value: ''
1316
+ };
806
1317
  });
807
1318
 
808
1319
  customHeaders.forEach(header => {
@@ -813,6 +1324,7 @@ function init(args) {
813
1324
  let duration;
814
1325
  try {
815
1326
  let res;
1327
+
816
1328
  let serviceUrl = await settings.get('serviceUrl');
817
1329
 
818
1330
  try {
@@ -825,7 +1337,9 @@ function init(args) {
825
1337
  account: null,
826
1338
  date: new Date().toISOString(),
827
1339
  event: 'test',
828
- data: { nonce: crypto.randomBytes(NONCE_BYTES).toString('base64url') }
1340
+ data: {
1341
+ nonce: crypto.randomBytes(NONCE_BYTES).toString('base64url')
1342
+ }
829
1343
  }),
830
1344
  headers,
831
1345
  dispatcher: httpAgent.retry
@@ -842,194 +1356,151 @@ function init(args) {
842
1356
  throw err;
843
1357
  }
844
1358
 
845
- return { success: true, target: webhooks, duration };
1359
+ return {
1360
+ success: true,
1361
+ target: webhooks,
1362
+ duration
1363
+ };
846
1364
  } catch (err) {
847
1365
  request.logger.error({ msg: 'Failed posting webhook', webhooks, event: 'test', err });
848
- return { success: false, target: webhooks, duration, error: err.message, code: err.code };
1366
+ return {
1367
+ success: false,
1368
+ target: webhooks,
1369
+ duration,
1370
+ error: err.message,
1371
+ code: err.code
1372
+ };
849
1373
  }
850
1374
  },
851
1375
  options: {
852
1376
  tags: ['test'],
853
1377
  validate: {
854
- options: { stripUnknown: true, abortEarly: false, convert: true },
1378
+ options: {
1379
+ stripUnknown: true,
1380
+ abortEarly: false,
1381
+ convert: true
1382
+ },
1383
+
855
1384
  failAction,
1385
+
856
1386
  payload: Joi.object({
857
1387
  webhooks: Joi.string()
858
- .uri({ scheme: ['http', 'https'], allowRelative: false })
859
- .allow(''),
1388
+ .uri({
1389
+ scheme: ['http', 'https'],
1390
+ allowRelative: false
1391
+ })
1392
+ .allow('')
1393
+ .example('https://myservice.com/imap/webhooks')
1394
+ .description('Webhook URL'),
860
1395
  customHeaders: Joi.string()
861
1396
  .allow('')
862
1397
  .trim()
863
1398
  .max(10 * 1024)
864
- .default(''),
1399
+ .default('')
1400
+ .description('Custom request headers'),
865
1401
  payload: Joi.string()
866
1402
  .max(1024 * 1024)
867
1403
  .empty('')
868
1404
  .trim()
1405
+ .description('Example JSON payload')
869
1406
  })
870
1407
  }
871
1408
  }
872
1409
  });
873
1410
 
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
- });
1411
+ // Webhook, template, gateway, and token routes are in admin-entities-routes.js
890
1412
 
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);
912
-
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
- )
1413
+ server.route({
1414
+ method: 'GET',
1415
+ path: '/admin/config/license',
1416
+ async handler(request, h) {
1417
+ await call({ cmd: 'checkLicense' });
1418
+
1419
+ let subexp = await settings.get('subexp');
1420
+ let expiresDays;
1421
+ if (subexp && !(request.app.licenseInfo && request.app.licenseInfo.details && request.app.licenseInfo.details.lt)) {
1422
+ let delayMs = new Date(subexp) - Date.now();
1423
+ expiresDays = Math.max(Math.ceil(delayMs / (24 * 3600 * 1000)), 0);
1424
+ }
1425
+
1426
+ return h.view(
1427
+ 'config/license',
1428
+ {
1429
+ pageTitle: 'License',
1430
+ menuLicense: true,
1431
+ hideLicenseWarning: true,
1432
+ menuConfig: true,
1433
+ menuConfigLicense: true,
1434
+
1435
+ subexp,
1436
+ expiresDays,
1437
+
1438
+ showLicenseText:
1439
+ !request.app.licenseInfo ||
1440
+ !request.app.licenseInfo.active ||
1441
+ (request.app.licenseInfo.details && request.app.licenseInfo.details.trial)
931
1442
  },
932
- { layout: 'app' }
1443
+ {
1444
+ layout: 'app'
1445
+ }
933
1446
  );
934
1447
  }
935
1448
  });
936
1449
 
937
1450
  server.route({
938
1451
  method: 'POST',
939
- path: '/admin/config/ai',
1452
+ path: '/admin/config/license',
940
1453
  async handler(request, h) {
941
1454
  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' };
1455
+ // update license
1456
+ const licenseInfo = await call({ cmd: 'updateLicense', license: request.payload.license });
1457
+ if (!licenseInfo) {
1458
+ let err = new Error('Failed to update license. Check license file contents.');
1459
+ err.statusCode = 403;
1460
+ err.details = { license: err.message };
954
1461
  throw err;
955
1462
  }
956
1463
 
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]);
1464
+ if (licenseInfo.active) {
1465
+ await request.flash({ type: 'info', message: `License activated` });
983
1466
  }
984
1467
 
985
- await request.flash({ type: 'info', message: `Configuration updated` });
986
- return h.redirect('/admin/config/ai');
1468
+ return h.redirect('/admin/config/license');
987
1469
  } 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
- });
1470
+ await request.flash({ type: 'danger', message: `Couldn't register license. Check the key and try again.` });
1471
+ request.logger.error({ msg: 'Failed to register license key', err });
1005
1472
 
1006
1473
  return h.view(
1007
- 'config/ai',
1474
+ 'config/license',
1008
1475
  {
1009
- pageTitle: 'AI Processing',
1476
+ pageTitle: 'License',
1010
1477
  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
- )
1478
+ menuConfigWebhooks: true,
1479
+
1480
+ errors: err.details,
1481
+ showLicenseText:
1482
+ (err.details && !!err.details.license) ||
1483
+ !request.app.licenseInfo ||
1484
+ !request.app.licenseInfo.active ||
1485
+ (request.app.licenseInfo.details && request.app.licenseInfo.details.trial)
1023
1486
  },
1024
- { layout: 'app' }
1487
+ {
1488
+ layout: 'app'
1489
+ }
1025
1490
  );
1026
1491
  }
1027
1492
  },
1028
1493
  options: {
1029
1494
  validate: {
1030
- options: { stripUnknown: true, abortEarly: false, convert: true },
1495
+ options: {
1496
+ stripUnknown: true,
1497
+ abortEarly: false,
1498
+ convert: true
1499
+ },
1500
+
1031
1501
  async failAction(request, h, err) {
1032
1502
  let errors = {};
1503
+
1033
1504
  if (err.details) {
1034
1505
  err.details.forEach(detail => {
1035
1506
  if (!errors[detail.path]) {
@@ -1038,200 +1509,155 @@ return true;`
1038
1509
  });
1039
1510
  }
1040
1511
 
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
- });
1512
+ await request.flash({ type: 'danger', message: `Couldn't register license. Check the key and try again.` });
1513
+ request.logger.error({ msg: 'Failed to register license key', err });
1058
1514
 
1059
1515
  return h
1060
1516
  .view(
1061
- 'config/ai',
1517
+ 'config/license',
1062
1518
  {
1063
- pageTitle: 'AI Processing',
1519
+ pageTitle: 'License',
1064
1520
  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
1521
+ menuConfigWebhooks: true,
1522
+
1523
+ errors,
1524
+
1525
+ showLicenseText:
1526
+ (errors && !!errors.license) ||
1527
+ !request.app.licenseInfo ||
1528
+ !request.app.licenseInfo.active ||
1529
+ (request.app.licenseInfo.details && request.app.licenseInfo.details.trial)
1078
1530
  },
1079
- { layout: 'app' }
1531
+ {
1532
+ layout: 'app'
1533
+ }
1080
1534
  )
1081
1535
  .takeover();
1082
1536
  },
1537
+
1083
1538
  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
- })
1539
+ license: Joi.string()
1540
+ .max(10 * 1024)
1541
+ .required()
1542
+ .example('-----BEGIN LICENSE-----\r\n...')
1543
+ .description('License file')
1544
+ }).label('RegisterLicense')
1098
1545
  }
1099
1546
  }
1100
1547
  });
1101
1548
 
1102
- // AI test prompt route
1103
1549
  server.route({
1104
1550
  method: 'POST',
1105
- path: '/admin/config/ai/test-prompt',
1106
- async handler(request) {
1551
+ path: '/admin/config/license/delete',
1552
+ async handler(request, h) {
1107
1553
  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
- }
1554
+ const licenseInfo = await call({ cmd: 'removeLicense' });
1555
+ if (!licenseInfo) {
1556
+ let err = new Error('Failed to clear license info');
1557
+ err.statusCode = 403;
1558
+ throw err;
1559
+ } else {
1560
+ await request.flash({ type: 'info', message: `License removed` });
1141
1561
  }
1142
1562
 
1143
- return { success: true, response };
1563
+ return h.redirect('/admin/config/license');
1144
1564
  } catch (err) {
1145
- request.logger.error({ msg: 'Failed to test prompt', err });
1146
- return { success: false, error: err.message };
1565
+ await request.flash({ type: 'danger', message: `Couldn't remove license. Try again.` });
1566
+ request.logger.error({ msg: 'Failed to unregister license key', err, token: request.payload.token, remoteAddress: request.app.ip });
1567
+ return h.redirect('/admin/config/license');
1147
1568
  }
1148
1569
  },
1149
1570
  options: {
1150
- payload: { maxBytes: MAX_BODY_SIZE, timeout: MAX_PAYLOAD_TIMEOUT },
1151
1571
  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
- })
1572
+ options: {
1573
+ stripUnknown: true,
1574
+ abortEarly: false,
1575
+ convert: true
1576
+ },
1577
+
1578
+ async failAction(request, h, err) {
1579
+ await request.flash({ type: 'danger', message: `Couldn't remove license. Try again.` });
1580
+ request.logger.error({ msg: 'Failed to unregister license key', err });
1581
+
1582
+ return h.redirect('/admin/config/license').takeover();
1583
+ },
1584
+
1585
+ payload: Joi.object({})
1164
1586
  }
1165
1587
  }
1166
1588
  });
1167
1589
 
1168
- // AI reload models route
1169
1590
  server.route({
1170
1591
  method: 'POST',
1171
- path: '/admin/config/ai/reload-models',
1592
+ path: '/admin/config/license/trial',
1172
1593
  async handler(request) {
1173
1594
  try {
1174
- request.logger.info({ msg: 'Reload models' });
1595
+ // provision new trial license
1175
1596
 
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
1597
+ let headers = {
1598
+ 'Content-Type': 'application/json',
1599
+ 'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
1600
+ };
1601
+
1602
+ let res = await fetchCmd(`${LICENSE_HOST}/licenses/trial`, {
1603
+ method: 'post',
1604
+ body: JSON.stringify({
1605
+ version: packageData.version,
1606
+ app: '@postalsys/emailengine-app',
1607
+ hostname: os.hostname() || 'localhost',
1608
+ url: (await settings.get('serviceUrl')) || ''
1609
+ }),
1610
+ headers,
1611
+ dispatcher: httpAgent.retry
1183
1612
  });
1184
1613
 
1185
- if (models && models.length) {
1186
- await settings.set('openAiModels', models);
1614
+ if (!res.ok) {
1615
+ let err = new Error(res.statusText || `Invalid response: ${res.status} ${res.statusText}`);
1616
+ err.statusCode = res.status;
1617
+
1618
+ try {
1619
+ err.response = await res.json();
1620
+ } catch (err) {
1621
+ // ignore
1622
+ }
1623
+
1624
+ throw err;
1187
1625
  }
1188
1626
 
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
- });
1627
+ const data = await res.json();
1206
1628
 
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
- }
1629
+ let licenseFile = `-----BEGIN LICENSE-----
1630
+ ${Buffer.from(data.content, 'base64url').toString('base64')}
1631
+ -----END LICENSE-----`;
1632
+
1633
+ const licenseInfo = await call({ cmd: 'updateLicense', license: licenseFile });
1634
+ if (!licenseInfo) {
1635
+ let err = new Error('Failed to update license. Check license file contents.');
1636
+ err.statusCode = 403;
1637
+ err.details = { license: err.message };
1638
+ throw err;
1639
+ }
1640
+
1641
+ if (licenseInfo.active) {
1642
+ await request.flash({ type: 'info', message: `Trial activated` });
1643
+ return { success: true, message: `Trial activated` };
1218
1644
  }
1645
+
1646
+ throw new Error('Failed to activate provisioned trial license');
1647
+ } catch (err) {
1648
+ request.logger.error({ msg: 'Failed to provision a trial license key', err, remoteAddress: request.app.ip });
1649
+ return { success: false, error: (err.response && err.response.error) || err.message };
1219
1650
  }
1220
- return { success: true };
1221
1651
  },
1222
1652
  options: {
1223
1653
  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
- })
1654
+ options: {
1655
+ stripUnknown: true,
1656
+ abortEarly: false,
1657
+ convert: true
1658
+ },
1659
+
1660
+ failAction
1235
1661
  }
1236
1662
  }
1237
1663
  });