emailengine-app 2.61.1 → 2.61.2

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 (136) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/data/google-crawlers.json +1 -1
  3. package/lib/account/account-state.js +248 -0
  4. package/lib/account.js +17 -178
  5. package/lib/api-routes/account-routes.js +1006 -0
  6. package/lib/api-routes/message-routes.js +1377 -0
  7. package/lib/consts.js +12 -2
  8. package/lib/email-client/base-client.js +282 -771
  9. package/lib/email-client/gmail/gmail-api.js +243 -0
  10. package/lib/email-client/gmail-client.js +145 -53
  11. package/lib/email-client/imap/mailbox.js +24 -698
  12. package/lib/email-client/imap/sync-operations.js +812 -0
  13. package/lib/email-client/imap-client.js +1 -1
  14. package/lib/email-client/message-builder.js +566 -0
  15. package/lib/email-client/notification-handler.js +314 -0
  16. package/lib/email-client/outlook/graph-api.js +326 -0
  17. package/lib/email-client/outlook-client.js +159 -113
  18. package/lib/email-client/smtp-pool-manager.js +196 -0
  19. package/lib/imapproxy/imap-server.js +3 -12
  20. package/lib/oauth/gmail.js +4 -4
  21. package/lib/oauth/mail-ru.js +30 -5
  22. package/lib/oauth/outlook.js +57 -3
  23. package/lib/oauth/pubsub/google.js +30 -11
  24. package/lib/oauth/scope-checker.js +202 -0
  25. package/lib/oauth2-apps.js +8 -4
  26. package/lib/redis-operations.js +484 -0
  27. package/lib/routes-ui.js +283 -2582
  28. package/lib/tools.js +4 -196
  29. package/lib/ui-routes/account-routes.js +1931 -0
  30. package/lib/ui-routes/admin-config-routes.js +1233 -0
  31. package/lib/ui-routes/admin-entities-routes.js +2367 -0
  32. package/lib/ui-routes/oauth-routes.js +992 -0
  33. package/lib/utils/network.js +237 -0
  34. package/package.json +9 -9
  35. package/sbom.json +1 -1
  36. package/static/js/app.js +5 -5
  37. package/static/licenses.html +78 -18
  38. package/translations/de.mo +0 -0
  39. package/translations/de.po +85 -82
  40. package/translations/en.mo +0 -0
  41. package/translations/en.po +63 -71
  42. package/translations/et.mo +0 -0
  43. package/translations/et.po +84 -82
  44. package/translations/fr.mo +0 -0
  45. package/translations/fr.po +85 -82
  46. package/translations/ja.mo +0 -0
  47. package/translations/ja.po +84 -82
  48. package/translations/messages.pot +74 -87
  49. package/translations/nl.mo +0 -0
  50. package/translations/nl.po +86 -82
  51. package/translations/pl.mo +0 -0
  52. package/translations/pl.po +84 -82
  53. package/views/account/security.hbs +4 -4
  54. package/views/accounts/account.hbs +13 -13
  55. package/views/accounts/register/imap-server.hbs +12 -12
  56. package/views/config/document-store/pre-processing/index.hbs +4 -2
  57. package/views/config/oauth/app.hbs +6 -7
  58. package/views/config/oauth/index.hbs +2 -2
  59. package/views/config/service.hbs +3 -4
  60. package/views/dashboard.hbs +5 -7
  61. package/views/error.hbs +22 -7
  62. package/views/gateways/gateway.hbs +2 -2
  63. package/views/partials/add_account_modal.hbs +7 -10
  64. package/views/partials/document_store_header.hbs +1 -1
  65. package/views/partials/editor_scope_info.hbs +0 -1
  66. package/views/partials/oauth_config_header.hbs +1 -1
  67. package/views/partials/side_menu.hbs +3 -3
  68. package/views/partials/webhook_form.hbs +2 -2
  69. package/views/templates/index.hbs +1 -1
  70. package/views/templates/template.hbs +8 -8
  71. package/views/tokens/index.hbs +6 -6
  72. package/views/tokens/new.hbs +1 -1
  73. package/views/webhooks/index.hbs +4 -4
  74. package/views/webhooks/webhook.hbs +7 -7
  75. package/workers/api.js +148 -2436
  76. package/workers/smtp.js +2 -1
  77. package/lib/imapproxy/imap-core/test/client.js +0 -46
  78. package/lib/imapproxy/imap-core/test/fixtures/append.eml +0 -1196
  79. package/lib/imapproxy/imap-core/test/fixtures/chunks.js +0 -44
  80. package/lib/imapproxy/imap-core/test/fixtures/fix1.eml +0 -6
  81. package/lib/imapproxy/imap-core/test/fixtures/fix2.eml +0 -599
  82. package/lib/imapproxy/imap-core/test/fixtures/fix3.eml +0 -32
  83. package/lib/imapproxy/imap-core/test/fixtures/fix4.eml +0 -6
  84. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.eml +0 -599
  85. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.js +0 -2740
  86. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.json +0 -1411
  87. package/lib/imapproxy/imap-core/test/fixtures/mimetree.js +0 -85
  88. package/lib/imapproxy/imap-core/test/fixtures/nodemailer.eml +0 -582
  89. package/lib/imapproxy/imap-core/test/fixtures/ryan_finnie_mime_torture.eml +0 -599
  90. package/lib/imapproxy/imap-core/test/fixtures/simple.eml +0 -42
  91. package/lib/imapproxy/imap-core/test/fixtures/simple.json +0 -164
  92. package/lib/imapproxy/imap-core/test/imap-compile-stream-test.js +0 -671
  93. package/lib/imapproxy/imap-core/test/imap-compiler-test.js +0 -272
  94. package/lib/imapproxy/imap-core/test/imap-indexer-test.js +0 -236
  95. package/lib/imapproxy/imap-core/test/imap-parser-test.js +0 -922
  96. package/lib/imapproxy/imap-core/test/memory-notifier.js +0 -129
  97. package/lib/imapproxy/imap-core/test/prepare.sh +0 -74
  98. package/lib/imapproxy/imap-core/test/protocol-test.js +0 -1756
  99. package/lib/imapproxy/imap-core/test/search-test.js +0 -1356
  100. package/lib/imapproxy/imap-core/test/test-client.js +0 -152
  101. package/lib/imapproxy/imap-core/test/test-server.js +0 -623
  102. package/lib/imapproxy/imap-core/test/tools-test.js +0 -22
  103. package/test/api-test.js +0 -899
  104. package/test/autoreply-test.js +0 -327
  105. package/test/bounce-test.js +0 -151
  106. package/test/complaint-test.js +0 -256
  107. package/test/fixtures/autoreply/LICENSE +0 -27
  108. package/test/fixtures/autoreply/rfc3834-01.eml +0 -23
  109. package/test/fixtures/autoreply/rfc3834-02.eml +0 -24
  110. package/test/fixtures/autoreply/rfc3834-03.eml +0 -26
  111. package/test/fixtures/autoreply/rfc3834-04.eml +0 -48
  112. package/test/fixtures/autoreply/rfc3834-05.eml +0 -19
  113. package/test/fixtures/autoreply/rfc3834-06.eml +0 -59
  114. package/test/fixtures/bounces/163.eml +0 -2521
  115. package/test/fixtures/bounces/fastmail.eml +0 -242
  116. package/test/fixtures/bounces/gmail.eml +0 -252
  117. package/test/fixtures/bounces/hotmail.eml +0 -655
  118. package/test/fixtures/bounces/mailru.eml +0 -121
  119. package/test/fixtures/bounces/outlook.eml +0 -1107
  120. package/test/fixtures/bounces/postfix.eml +0 -101
  121. package/test/fixtures/bounces/rambler.eml +0 -116
  122. package/test/fixtures/bounces/workmail.eml +0 -142
  123. package/test/fixtures/bounces/yahoo.eml +0 -139
  124. package/test/fixtures/bounces/zoho.eml +0 -83
  125. package/test/fixtures/bounces/zonemta.eml +0 -100
  126. package/test/fixtures/complaints/LICENSE +0 -27
  127. package/test/fixtures/complaints/amazonses.eml +0 -72
  128. package/test/fixtures/complaints/dmarc.eml +0 -59
  129. package/test/fixtures/complaints/hotmail.eml +0 -49
  130. package/test/fixtures/complaints/optout.eml +0 -40
  131. package/test/fixtures/complaints/standard-arf.eml +0 -68
  132. package/test/fixtures/complaints/yahoo.eml +0 -68
  133. package/test/oauth2-apps-test.js +0 -301
  134. package/test/sendonly-test.js +0 -160
  135. package/test/test-config.js +0 -34
  136. package/test/webhooks-server.js +0 -39
@@ -0,0 +1,1233 @@
1
+ 'use strict';
2
+
3
+ const Joi = require('joi');
4
+ const crypto = require('crypto');
5
+ const he = require('he');
6
+ const { simpleParser } = require('mailparser');
7
+ const libmime = require('libmime');
8
+
9
+ const settings = require('../settings');
10
+ const { redis, submitQueue, notifyQueue, documentsQueue } = require('../db');
11
+ const getSecret = require('../get-secret');
12
+ const { llmPreProcess } = require('../llm-pre-process');
13
+ const { locales } = require('../translations');
14
+ const consts = require('../consts');
15
+ const packageData = require('../../package.json');
16
+ const timezonesList = require('timezones-list').default;
17
+
18
+ const { failAction, getByteSize, formatByteSize, getDuration, readEnvValue, hasEnvValue, retryAgent } = require('../tools');
19
+
20
+ const { settingsSchema } = require('../schemas');
21
+
22
+ const { DEFAULT_MAX_LOG_LINES, DEFAULT_DELIVERY_ATTEMPTS, REDIS_PREFIX, NONCE_BYTES } = consts;
23
+
24
+ const { fetch: fetchCmd } = require('undici');
25
+
26
+ const OPEN_AI_MODELS = [
27
+ { name: 'GPT-3 (instruct)', id: 'gpt-3.5-turbo-instruct' },
28
+ { name: 'GPT-3 (chat)', id: 'gpt-3.5-turbo' },
29
+ { name: 'GPT-4', id: 'gpt-4' }
30
+ ];
31
+
32
+ const IMAP_INDEXERS = [
33
+ {
34
+ id: 'full',
35
+ name: 'Full (Default): Builds a comprehensive index that detects new, deleted, and updated emails. This method is slower and uses more storage in Redis.'
36
+ },
37
+ {
38
+ id: 'fast',
39
+ name: 'Fast: Quickly detects newly received emails with minimal storage usage in Redis. It does not detect updated or deleted emails.'
40
+ }
41
+ ];
42
+
43
+ const notificationTypes = Object.keys(consts)
44
+ .map(key => {
45
+ if (/_NOTIFY$/.test(key)) {
46
+ return key.replace(/_NOTIFY$/, '');
47
+ }
48
+ return false;
49
+ })
50
+ .filter(key => key)
51
+ .map(key => ({
52
+ key,
53
+ name: consts[`${key}_NOTIFY`],
54
+ description: consts[`${key}_DESCRIPTION`]
55
+ }));
56
+
57
+ const ADMIN_ACCESS_ADDRESSES = hasEnvValue('EENGINE_ADMIN_ACCESS_ADDRESSES')
58
+ ? readEnvValue('EENGINE_ADMIN_ACCESS_ADDRESSES')
59
+ .split(',')
60
+ .map(v => v.trim())
61
+ .filter(v => v)
62
+ : null;
63
+
64
+ const MAX_BODY_SIZE = getByteSize(readEnvValue('EENGINE_MAX_BODY_SIZE')) || consts.DEFAULT_MAX_BODY_SIZE;
65
+ const MAX_PAYLOAD_TIMEOUT = getDuration(readEnvValue('EENGINE_MAX_PAYLOAD_TIMEOUT')) || consts.DEFAULT_MAX_PAYLOAD_TIMEOUT;
66
+
67
+ // Validation schemas
68
+ const configWebhooksSchema = {
69
+ webhooksEnabled: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
70
+ webhooks: Joi.string()
71
+ .uri({ scheme: ['http', 'https'], allowRelative: false })
72
+ .allow('')
73
+ .example('https://myservice.com/imap/webhooks'),
74
+ notifyAll: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
75
+ headersAll: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
76
+ notifyHeaders: Joi.string().empty('').trim(),
77
+ notifyText: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
78
+ notifyWebSafeHtml: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
79
+ notifyTextSize: Joi.alternatives().try(
80
+ Joi.number().empty('').integer().min(0),
81
+ Joi.string().custom((value, helpers) => {
82
+ let nr = getByteSize(value);
83
+ if (typeof nr !== 'number' || nr < 0) {
84
+ return helpers.error('any.invalid');
85
+ }
86
+ return nr;
87
+ }, 'Byte size conversion')
88
+ ),
89
+ notifyCalendarEvents: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
90
+ inboxNewOnly: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
91
+ notifyAttachments: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
92
+ notifyAttachmentSize: Joi.alternatives().try(
93
+ Joi.number().empty('').integer().min(0),
94
+ Joi.string().custom((value, helpers) => {
95
+ let nr = getByteSize(value);
96
+ if (typeof nr !== 'number' || nr < 0) {
97
+ return helpers.error('any.invalid');
98
+ }
99
+ return nr;
100
+ }, 'Byte size conversion')
101
+ ),
102
+ customHeaders: Joi.string()
103
+ .allow('')
104
+ .trim()
105
+ .max(10 * 1024)
106
+ };
107
+
108
+ for (let type of notificationTypes) {
109
+ configWebhooksSchema[`notify_${type.name}`] = Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false);
110
+ }
111
+
112
+ const configLoggingSchema = {
113
+ all: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
114
+ maxLogLines: Joi.number().integer().empty('').min(0).max(10000000).default(DEFAULT_MAX_LOG_LINES)
115
+ };
116
+
117
+ // Helper functions
118
+ async function getOpenAiModels(models, selectedModel) {
119
+ let modelList = (await settings.get('openAiModels')) || structuredClone(models);
120
+
121
+ if (selectedModel && !modelList.find(model => model.id === selectedModel)) {
122
+ modelList.unshift({ name: selectedModel, id: selectedModel });
123
+ }
124
+
125
+ return modelList.map(model => {
126
+ model.selected = model.id === selectedModel;
127
+ return model;
128
+ });
129
+ }
130
+
131
+ async function getOpenAiError(gt) {
132
+ let openAiErrorData = await redis.get(`${REDIS_PREFIX}:openai:error`);
133
+ if (openAiErrorData) {
134
+ try {
135
+ let { error, time } = JSON.parse(openAiErrorData);
136
+ return { error, time: (gt && gt.dateFns.formatDistance(new Date(time), new Date(), { addSuffix: true })) || time };
137
+ } catch (err) {
138
+ return false;
139
+ }
140
+ }
141
+ return false;
142
+ }
143
+
144
+ async function getExampleDocumentsPayloads() {
145
+ const exampleDocumentsPayloads = require('../payload-examples-documents.json');
146
+ let examples = structuredClone(exampleDocumentsPayloads);
147
+ let serviceUrl = await settings.get('serviceUrl');
148
+ for (let example of examples) {
149
+ if (example.serviceUrl) {
150
+ example.serviceUrl = serviceUrl || example.serviceUrl;
151
+ }
152
+ }
153
+ return examples;
154
+ }
155
+
156
+ function init(args) {
157
+ const { server, call } = args;
158
+
159
+ const getDefaultPrompt = async () =>
160
+ await call({
161
+ cmd: 'openAiDefaultPrompt'
162
+ });
163
+
164
+ // Webhooks config routes
165
+ server.route({
166
+ method: 'GET',
167
+ path: '/admin/config/webhooks',
168
+ async handler(request, h) {
169
+ const notifyHeaders = (await settings.get('notifyHeaders')) || [];
170
+ const webhookEvents = (await settings.get('webhookEvents')) || [];
171
+ const notifyText = (await settings.get('notifyText')) || false;
172
+ const notifyWebSafeHtml = (await settings.get('notifyWebSafeHtml')) || false;
173
+ const notifyTextSize = Number(await settings.get('notifyTextSize')) || 0;
174
+ const notifyCalendarEvents = (await settings.get('notifyCalendarEvents')) || false;
175
+ const notifyAttachments = (await settings.get('notifyAttachments')) || false;
176
+ const notifyAttachmentSize = Number(await settings.get('notifyAttachmentSize')) || 0;
177
+ const inboxNewOnly = (await settings.get('inboxNewOnly')) || false;
178
+ const customHeaders = (await settings.get('webhooksCustomHeaders')) || [];
179
+
180
+ let webhooksEnabled = await settings.get('webhooksEnabled');
181
+ let values = {
182
+ webhooksEnabled: webhooksEnabled !== null ? !!webhooksEnabled : false,
183
+ webhooks: (await settings.get('webhooks')) || '',
184
+ notifyAll: webhookEvents.includes('*'),
185
+ inboxNewOnly,
186
+ headersAll: notifyHeaders.includes('*'),
187
+ notifyHeaders: notifyHeaders
188
+ .filter(entry => entry !== '*')
189
+ .map(entry => entry.replace(/^mime|^dkim|-id$|^.|-./gi, c => c.toUpperCase()))
190
+ .join('\n'),
191
+ notifyText,
192
+ notifyWebSafeHtml,
193
+ notifyTextSize: notifyTextSize ? formatByteSize(notifyTextSize) : '',
194
+ notifyCalendarEvents,
195
+ notifyAttachments,
196
+ notifyAttachmentSize: notifyAttachmentSize ? formatByteSize(notifyAttachmentSize) : '',
197
+ customHeaders: []
198
+ .concat(customHeaders || [])
199
+ .map(entry => `${entry.key}: ${entry.value}`.trim())
200
+ .join('\n')
201
+ };
202
+
203
+ return h.view(
204
+ 'config/webhooks',
205
+ {
206
+ pageTitle: 'Webhooks',
207
+ menuConfig: true,
208
+ menuConfigWebhooks: true,
209
+ notificationTypes: notificationTypes.map(type =>
210
+ Object.assign({}, type, { checked: webhookEvents.includes(type.name), isMessageNew: type.name === 'messageNew' })
211
+ ),
212
+ values,
213
+ webhookErrorFlag: await settings.get('webhookErrorFlag'),
214
+ documentStoreEnabled: (await settings.get('documentStoreEnabled')) || false
215
+ },
216
+ { layout: 'app' }
217
+ );
218
+ }
219
+ });
220
+
221
+ server.route({
222
+ method: 'POST',
223
+ path: '/admin/config/webhooks',
224
+ async handler(request, h) {
225
+ try {
226
+ let customHeaders = request.payload.customHeaders
227
+ .split(/[\r\n]+/)
228
+ .map(header => header.trim())
229
+ .filter(header => header)
230
+ .map(line => {
231
+ let sep = line.indexOf(':');
232
+ if (sep >= 0) {
233
+ return { key: line.substring(0, sep).trim(), value: line.substring(sep + 1).trim() };
234
+ }
235
+ return { key: line, value: '' };
236
+ });
237
+
238
+ const data = {
239
+ webhooksEnabled: request.payload.webhooksEnabled,
240
+ webhooks: request.payload.webhooks,
241
+ notifyText: request.payload.notifyText,
242
+ notifyWebSafeHtml: request.payload.notifyWebSafeHtml,
243
+ notifyTextSize: request.payload.notifyTextSize || 0,
244
+ notifyCalendarEvents: request.payload.notifyCalendarEvents,
245
+ notifyAttachments: request.payload.notifyAttachments,
246
+ notifyAttachmentSize: request.payload.notifyAttachmentSize,
247
+ inboxNewOnly: request.payload.inboxNewOnly,
248
+ webhookEvents: notificationTypes.filter(type => !!request.payload[`notify_${type.name}`]).map(type => type.name),
249
+ notifyHeaders: (request.payload.notifyHeaders || '')
250
+ .split(/\r?\n/)
251
+ .map(line => line.toLowerCase().trim())
252
+ .filter(line => line),
253
+ webhooksCustomHeaders: customHeaders
254
+ };
255
+
256
+ if (request.payload.notifyAll) {
257
+ data.webhookEvents.push('*');
258
+ }
259
+
260
+ if (request.payload.headersAll) {
261
+ data.notifyHeaders.push('*');
262
+ }
263
+
264
+ for (let key of Object.keys(data)) {
265
+ await settings.set(key, data[key]);
266
+ }
267
+
268
+ if (!data.webhooksEnabled) {
269
+ await settings.clear('webhookErrorFlag');
270
+ }
271
+
272
+ await request.flash({ type: 'info', message: `Configuration updated` });
273
+ return h.redirect('/admin/config/webhooks');
274
+ } catch (err) {
275
+ await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
276
+ request.logger.error({ msg: 'Failed to update configuration', err });
277
+
278
+ return h.view(
279
+ 'config/webhooks',
280
+ {
281
+ pageTitle: 'Webhooks',
282
+ menuConfig: true,
283
+ menuConfigWebhooks: true,
284
+ notificationTypes: notificationTypes.map(type =>
285
+ Object.assign({}, type, { checked: !!request.payload[`notify_${type.name}`], isMessageNew: type.name === 'messageNew' })
286
+ ),
287
+ webhookErrorFlag: await settings.get('webhookErrorFlag'),
288
+ documentStoreEnabled: (await settings.get('documentStoreEnabled')) || false
289
+ },
290
+ { layout: 'app' }
291
+ );
292
+ }
293
+ },
294
+ options: {
295
+ validate: {
296
+ options: { stripUnknown: true, abortEarly: false, convert: true },
297
+ async failAction(request, h, err) {
298
+ let errors = {};
299
+ if (err.details) {
300
+ err.details.forEach(detail => {
301
+ if (!errors[detail.path]) {
302
+ errors[detail.path] = detail.message;
303
+ }
304
+ });
305
+ }
306
+
307
+ await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
308
+ request.logger.error({ msg: 'Failed to update configuration', err });
309
+
310
+ return h
311
+ .view(
312
+ 'config/webhooks',
313
+ {
314
+ pageTitle: 'Webhooks',
315
+ menuConfig: true,
316
+ menuConfigWebhooks: true,
317
+ notificationTypes: notificationTypes.map(type =>
318
+ Object.assign({}, type, {
319
+ checked: !!request.payload[`notify_${type.name}`],
320
+ isMessageNew: type.name === 'messageNew',
321
+ error: errors[`notify_${type.name}`]
322
+ })
323
+ ),
324
+ errors,
325
+ webhookErrorFlag: await settings.get('webhookErrorFlag'),
326
+ documentStoreEnabled: (await settings.get('documentStoreEnabled')) || false
327
+ },
328
+ { layout: 'app' }
329
+ )
330
+ .takeover();
331
+ },
332
+ payload: Joi.object(configWebhooksSchema)
333
+ }
334
+ }
335
+ });
336
+
337
+ // Service config routes
338
+ server.route({
339
+ method: 'GET',
340
+ path: '/admin/config/service',
341
+ async handler(request, h) {
342
+ let trackSentMessages = (await settings.get('trackSentMessages')) || false;
343
+
344
+ const values = {
345
+ serviceUrl: (await settings.get('serviceUrl')) || null,
346
+ serviceSecret: (await settings.get('serviceSecret')) || null,
347
+ queueKeep: (await settings.get('queueKeep')) || 0,
348
+ deliveryAttempts: await settings.get('deliveryAttempts'),
349
+ imapIndexer: (await settings.get('imapIndexer')) || 'full',
350
+ pageBrandName: (await settings.get('pageBrandName')) || '',
351
+ templateHeader: (await settings.get('templateHeader')) || '',
352
+ templateHtmlHead: (await settings.get('templateHtmlHead')) || '',
353
+ scriptEnv: (await settings.get('scriptEnv')) || '',
354
+ enableTokens: !(await settings.get('disableTokens')),
355
+ enableApiProxy: (await settings.get('enableApiProxy')) || false,
356
+ trackClicks: await settings.get('trackClicks'),
357
+ trackOpens: await settings.get('trackOpens'),
358
+ resolveGmailCategories: (await settings.get('resolveGmailCategories')) || false,
359
+ enableOAuthTokensApi: (await settings.get('enableOAuthTokensApi')) || false,
360
+ ignoreMailCertErrors: (await settings.get('ignoreMailCertErrors')) || false,
361
+ locale: (await settings.get('locale')) || false,
362
+ timezone: (await settings.get('timezone')) || false
363
+ };
364
+
365
+ if (typeof values.trackClicks !== 'boolean') {
366
+ values.trackClicks = trackSentMessages;
367
+ }
368
+
369
+ if (typeof values.trackOpens !== 'boolean') {
370
+ values.trackOpens = trackSentMessages;
371
+ }
372
+
373
+ if (typeof values.deliveryAttempts !== 'number') {
374
+ values.deliveryAttempts = DEFAULT_DELIVERY_ATTEMPTS;
375
+ }
376
+
377
+ return h.view(
378
+ 'config/service',
379
+ {
380
+ pageTitle: 'General Settings',
381
+ menuConfig: true,
382
+ menuConfigService: true,
383
+ encryption: await getSecret(),
384
+ locales: locales.map(locale => Object.assign({ selected: locale.locale === values.locale }, locale)),
385
+ timezones: timezonesList.map(entry => ({
386
+ name: entry.label,
387
+ timezone: entry.tzCode,
388
+ selected: entry.tzCode === values.timezone
389
+ })),
390
+ imapIndexers: structuredClone(IMAP_INDEXERS).map(entry => {
391
+ if (entry.id === values.imapIndexer) {
392
+ entry.selected = true;
393
+ }
394
+ return entry;
395
+ }),
396
+ adminAccessLimit: ADMIN_ACCESS_ADDRESSES && ADMIN_ACCESS_ADDRESSES.length,
397
+ values
398
+ },
399
+ { layout: 'app' }
400
+ );
401
+ }
402
+ });
403
+
404
+ server.route({
405
+ method: 'POST',
406
+ path: '/admin/config/service',
407
+ async handler(request, h) {
408
+ try {
409
+ let data = {
410
+ serviceSecret: request.payload.serviceSecret,
411
+ queueKeep: request.payload.queueKeep,
412
+ pageBrandName: request.payload.pageBrandName,
413
+ templateHeader: request.payload.templateHeader,
414
+ templateHtmlHead: request.payload.templateHtmlHead,
415
+ scriptEnv: request.payload.scriptEnv,
416
+ disableTokens: !request.payload.enableTokens,
417
+ enableApiProxy: request.payload.enableApiProxy,
418
+ trackOpens: request.payload.trackOpens,
419
+ trackClicks: request.payload.trackClicks,
420
+ resolveGmailCategories: request.payload.resolveGmailCategories,
421
+ enableOAuthTokensApi: request.payload.enableOAuthTokensApi,
422
+ ignoreMailCertErrors: request.payload.ignoreMailCertErrors,
423
+ locale: request.payload.locale,
424
+ timezone: request.payload.timezone,
425
+ deliveryAttempts: request.payload.deliveryAttempts,
426
+ imapIndexer: request.payload.imapIndexer
427
+ };
428
+
429
+ if (request.payload.serviceUrl) {
430
+ let url = new URL(request.payload.serviceUrl);
431
+ data.serviceUrl = url.origin;
432
+ }
433
+
434
+ for (let key of Object.keys(data)) {
435
+ await settings.set(key, data[key]);
436
+ }
437
+
438
+ await request.flash({ type: 'info', message: `Configuration updated` });
439
+ return h.redirect('/admin/config/service');
440
+ } catch (err) {
441
+ await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
442
+ request.logger.error({ msg: 'Failed to update configuration', err });
443
+
444
+ return h.view(
445
+ 'config/service',
446
+ {
447
+ pageTitle: 'General Settings',
448
+ menuConfig: true,
449
+ menuConfigService: true,
450
+ locales: locales.map(locale => Object.assign({ selected: locale.locale === request.payload.locale }, locale)),
451
+ encryption: await getSecret(),
452
+ timezones: timezonesList.map(entry => ({
453
+ name: entry.label,
454
+ timezone: entry.tzCode,
455
+ selected: entry.tzCode === request.payload.timezone
456
+ })),
457
+ imapIndexers: structuredClone(IMAP_INDEXERS).map(entry => {
458
+ if (entry.id === request.payload.imapIndexer) {
459
+ entry.selected = true;
460
+ }
461
+ return entry;
462
+ }),
463
+ adminAccessLimit: ADMIN_ACCESS_ADDRESSES && ADMIN_ACCESS_ADDRESSES.length
464
+ },
465
+ { layout: 'app' }
466
+ );
467
+ }
468
+ },
469
+ options: {
470
+ validate: {
471
+ options: { stripUnknown: true, abortEarly: false, convert: true },
472
+ async failAction(request, h, err) {
473
+ let errors = {};
474
+ if (err.details) {
475
+ err.details.forEach(detail => {
476
+ if (!errors[detail.path]) {
477
+ errors[detail.path] = detail.message;
478
+ }
479
+ });
480
+ }
481
+
482
+ await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
483
+ request.logger.error({ msg: 'Failed to update configuration', err });
484
+
485
+ return h
486
+ .view(
487
+ 'config/service',
488
+ {
489
+ pageTitle: 'General Settings',
490
+ menuConfig: true,
491
+ menuConfigService: true,
492
+ locales: locales.map(locale => Object.assign({ selected: locale.locale === request.payload.locale }, locale)),
493
+ encryption: await getSecret(),
494
+ timezones: timezonesList.map(entry => ({
495
+ name: entry.label,
496
+ timezone: entry.tzCode,
497
+ selected: entry.tzCode === request.payload.timezone
498
+ })),
499
+ imapIndexers: structuredClone(IMAP_INDEXERS).map(entry => {
500
+ if (entry.id === request.payload.imapIndexer) {
501
+ entry.selected = true;
502
+ }
503
+ return entry;
504
+ }),
505
+ adminAccessLimit: ADMIN_ACCESS_ADDRESSES && ADMIN_ACCESS_ADDRESSES.length,
506
+ errors
507
+ },
508
+ { layout: 'app' }
509
+ )
510
+ .takeover();
511
+ },
512
+ payload: Joi.object({
513
+ serviceUrl: settingsSchema.serviceUrl,
514
+ serviceSecret: settingsSchema.serviceSecret,
515
+ queueKeep: settingsSchema.queueKeep.default(0),
516
+ deliveryAttempts: settingsSchema.deliveryAttempts.default(DEFAULT_DELIVERY_ATTEMPTS),
517
+ imapIndexer: settingsSchema.imapIndexer.default('full'),
518
+ pageBrandName: settingsSchema.pageBrandName.default(''),
519
+ templateHeader: settingsSchema.templateHeader.default(''),
520
+ templateHtmlHead: settingsSchema.templateHtmlHead.default(''),
521
+ scriptEnv: settingsSchema.scriptEnv.default(''),
522
+ enableApiProxy: settingsSchema.enableApiProxy.default(false),
523
+ trackOpens: settingsSchema.trackOpens.default(false),
524
+ trackClicks: settingsSchema.trackClicks.default(false),
525
+ resolveGmailCategories: settingsSchema.resolveGmailCategories.default(false),
526
+ ignoreMailCertErrors: settingsSchema.ignoreMailCertErrors.default(false),
527
+ enableOAuthTokensApi: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
528
+ enableTokens: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false),
529
+ locale: settingsSchema.locale
530
+ .empty('')
531
+ .valid(...locales.map(locale => locale.locale))
532
+ .default('en'),
533
+ timezone: settingsSchema.timezone.empty('')
534
+ })
535
+ }
536
+ }
537
+ });
538
+
539
+ // Service preview route
540
+ server.route({
541
+ method: 'POST',
542
+ path: '/admin/config/service/preview',
543
+ async handler(request, h) {
544
+ return h.view(
545
+ 'config/service-preview',
546
+ {
547
+ pageBrandName: request.payload.pageBrandName,
548
+ embeddedTemplateHeader: request.payload.templateHeader,
549
+ embeddedTemplateHtmlHead: request.payload.templateHtmlHead
550
+ },
551
+ { layout: 'public' }
552
+ );
553
+ },
554
+ options: {
555
+ validate: {
556
+ options: { stripUnknown: true, abortEarly: false, convert: true },
557
+ async failAction(request, h, err) {
558
+ request.logger.error({ msg: 'Failed to process preview', err });
559
+ return h.redirect('/admin').takeover();
560
+ },
561
+ payload: Joi.object({
562
+ pageBrandName: settingsSchema.pageBrandName.default(''),
563
+ templateHeader: settingsSchema.templateHeader.default(''),
564
+ templateHtmlHead: settingsSchema.templateHtmlHead.default('')
565
+ })
566
+ }
567
+ }
568
+ });
569
+
570
+ // Clear error route
571
+ server.route({
572
+ method: 'POST',
573
+ path: '/admin/config/clear-error',
574
+ async handler(request) {
575
+ switch (request.payload.alert) {
576
+ case 'open-ai':
577
+ await redis.del(`${REDIS_PREFIX}:openai:error`);
578
+ break;
579
+ case 'webhook-default':
580
+ await settings.clear('webhookErrorFlag');
581
+ break;
582
+ case 'webhook-route':
583
+ if (request.payload.entry) {
584
+ await redis.hdel(`${REDIS_PREFIX}wh:c`, `${request.payload.entry}:webhookErrorFlag`);
585
+ }
586
+ break;
587
+ }
588
+ return { success: true };
589
+ },
590
+ options: {
591
+ validate: {
592
+ options: { stripUnknown: true, abortEarly: false, convert: true },
593
+ failAction,
594
+ payload: Joi.object({
595
+ alert: Joi.string().required().max(1024),
596
+ entry: Joi.string().empty('').max(1024).trim()
597
+ })
598
+ }
599
+ }
600
+ });
601
+
602
+ // Service clean route
603
+ server.route({
604
+ method: 'POST',
605
+ path: '/admin/config/service/clean',
606
+ async handler(request) {
607
+ let errors = [];
608
+ for (let queue of [submitQueue, notifyQueue, documentsQueue]) {
609
+ for (let type of ['failed', 'completed']) {
610
+ try {
611
+ await queue.clean(1000, 100000, type);
612
+ request.logger.trace({ msg: 'Queue cleaned', queue: queue.name, type });
613
+ } catch (err) {
614
+ request.logger.error({ msg: 'Failed to clean queue', queue: queue.name, type, err });
615
+ errors.push(err.message);
616
+ }
617
+ }
618
+ }
619
+
620
+ if (errors.length) {
621
+ return { success: false, error: 'Cleaning failed for some queues' };
622
+ }
623
+ return { success: true };
624
+ },
625
+ options: {
626
+ validate: {
627
+ options: { stripUnknown: true, abortEarly: false, convert: true }
628
+ }
629
+ }
630
+ });
631
+
632
+ // Logging config routes
633
+ server.route({
634
+ method: 'GET',
635
+ path: '/admin/config/logging',
636
+ async handler(request, h) {
637
+ let values = (await settings.get('logs')) || {};
638
+ if (typeof values.maxLogLines === 'undefined') {
639
+ values.maxLogLines = DEFAULT_MAX_LOG_LINES;
640
+ }
641
+ values.accounts = [].concat(values.accounts || []).join('\n');
642
+
643
+ return h.view(
644
+ 'config/logging',
645
+ {
646
+ pageTitle: 'Logging',
647
+ menuConfig: true,
648
+ menuConfigLogging: true,
649
+ values
650
+ },
651
+ { layout: 'app' }
652
+ );
653
+ }
654
+ });
655
+
656
+ server.route({
657
+ method: 'POST',
658
+ path: '/admin/config/logging',
659
+ async handler(request, h) {
660
+ try {
661
+ const data = {
662
+ logs: {
663
+ all: !!request.payload.all,
664
+ maxLogLines: request.payload.maxLogLines || 0
665
+ }
666
+ };
667
+
668
+ for (let key of Object.keys(data)) {
669
+ await settings.set(key, data[key]);
670
+ }
671
+
672
+ await request.flash({ type: 'info', message: `Configuration updated` });
673
+ return h.redirect('/admin/config/logging');
674
+ } catch (err) {
675
+ await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
676
+ request.logger.error({ msg: 'Failed to update configuration', err });
677
+
678
+ return h.view(
679
+ 'config/logging',
680
+ {
681
+ pageTitle: 'Logging',
682
+ menuConfig: true,
683
+ menuConfigWebhooks: true
684
+ },
685
+ { layout: 'app' }
686
+ );
687
+ }
688
+ },
689
+ options: {
690
+ validate: {
691
+ options: { stripUnknown: true, abortEarly: false, convert: true },
692
+ async failAction(request, h, err) {
693
+ let errors = {};
694
+ if (err.details) {
695
+ err.details.forEach(detail => {
696
+ if (!errors[detail.path]) {
697
+ errors[detail.path] = detail.message;
698
+ }
699
+ });
700
+ }
701
+
702
+ await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
703
+ request.logger.error({ msg: 'Failed to update configuration', err });
704
+
705
+ return h
706
+ .view(
707
+ 'config/logging',
708
+ {
709
+ pageTitle: 'Logging',
710
+ menuConfig: true,
711
+ menuConfigWebhooks: true,
712
+ errors
713
+ },
714
+ { layout: 'app' }
715
+ )
716
+ .takeover();
717
+ },
718
+ payload: Joi.object(configLoggingSchema)
719
+ }
720
+ }
721
+ });
722
+
723
+ // Logging reconnect route
724
+ server.route({
725
+ method: 'POST',
726
+ path: '/admin/config/logging/reconnect',
727
+ async handler(request) {
728
+ try {
729
+ let requested = 0;
730
+ for (let account of request.payload.accounts) {
731
+ request.logger.info({ msg: 'Request reconnect for logging', account });
732
+ try {
733
+ await call({ cmd: 'update', account });
734
+ requested++;
735
+ } catch (err) {
736
+ request.logger.error({ msg: 'Reconnect request failed', action: 'request_reconnect', account, err });
737
+ }
738
+ }
739
+
740
+ return { success: true, accounts: requested };
741
+ } catch (err) {
742
+ request.logger.error({ msg: 'Failed to request reconnect', err, accounts: request.payload.accounts });
743
+ return { success: false, error: err.message };
744
+ }
745
+ },
746
+ options: {
747
+ validate: {
748
+ options: { stripUnknown: true, abortEarly: false, convert: true },
749
+ failAction,
750
+ payload: Joi.object({
751
+ accounts: Joi.array().items(Joi.string().max(256)).default([]).label('LoggedAccounts')
752
+ })
753
+ }
754
+ }
755
+ });
756
+
757
+ // Webhooks test route
758
+ server.route({
759
+ method: 'POST',
760
+ path: '/admin/config/webhooks/test',
761
+ async handler(request) {
762
+ let headers = {
763
+ 'Content-Type': 'application/json',
764
+ 'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
765
+ };
766
+
767
+ const webhooks = request.payload.webhooks;
768
+ if (!webhooks) {
769
+ return { success: false, target: webhooks, error: 'Webhook URL is not set' };
770
+ }
771
+
772
+ let parsed = new URL(webhooks);
773
+ let username, password;
774
+
775
+ if (parsed.username) {
776
+ username = he.decode(parsed.username);
777
+ parsed.username = '';
778
+ }
779
+
780
+ if (parsed.password) {
781
+ password = he.decode(parsed.password);
782
+ parsed.password = '';
783
+ }
784
+
785
+ if (username || password) {
786
+ headers.Authorization = `Basic ${Buffer.from(he.encode(username || '') + ':' + he.encode(password || '')).toString('base64')}`;
787
+ }
788
+
789
+ let customHeaders = request.payload.customHeaders
790
+ .split(/[\r\n]+/)
791
+ .map(header => header.trim())
792
+ .filter(header => header)
793
+ .map(line => {
794
+ let sep = line.indexOf(':');
795
+ if (sep >= 0) {
796
+ return { key: line.substring(0, sep).trim(), value: line.substring(sep + 1).trim() };
797
+ }
798
+ return { key: line, value: '' };
799
+ });
800
+
801
+ customHeaders.forEach(header => {
802
+ headers[header.key] = header.value;
803
+ });
804
+
805
+ let start = Date.now();
806
+ let duration;
807
+ try {
808
+ let res;
809
+ let serviceUrl = await settings.get('serviceUrl');
810
+
811
+ try {
812
+ res = await fetchCmd(parsed.toString(), {
813
+ method: 'post',
814
+ body:
815
+ request.payload.payload ||
816
+ JSON.stringify({
817
+ serviceUrl,
818
+ account: null,
819
+ date: new Date().toISOString(),
820
+ event: 'test',
821
+ data: { nonce: crypto.randomBytes(NONCE_BYTES).toString('base64url') }
822
+ }),
823
+ headers,
824
+ dispatcher: retryAgent
825
+ });
826
+ duration = Date.now() - start;
827
+ } catch (err) {
828
+ duration = Date.now() - start;
829
+ throw err.cause || err;
830
+ }
831
+
832
+ if (!res.ok) {
833
+ let err = new Error(res.statusText || `Invalid response: ${res.status} ${res.statusText}`);
834
+ err.statusCode = res.status;
835
+ throw err;
836
+ }
837
+
838
+ return { success: true, target: webhooks, duration };
839
+ } catch (err) {
840
+ request.logger.error({ msg: 'Failed posting webhook', webhooks, event: 'test', err });
841
+ return { success: false, target: webhooks, duration, error: err.message, code: err.code };
842
+ }
843
+ },
844
+ options: {
845
+ tags: ['test'],
846
+ validate: {
847
+ options: { stripUnknown: true, abortEarly: false, convert: true },
848
+ failAction,
849
+ payload: Joi.object({
850
+ webhooks: Joi.string()
851
+ .uri({ scheme: ['http', 'https'], allowRelative: false })
852
+ .allow(''),
853
+ customHeaders: Joi.string()
854
+ .allow('')
855
+ .trim()
856
+ .max(10 * 1024)
857
+ .default(''),
858
+ payload: Joi.string()
859
+ .max(1024 * 1024)
860
+ .empty('')
861
+ .trim()
862
+ })
863
+ }
864
+ }
865
+ });
866
+
867
+ // AI config routes
868
+ server.route({
869
+ method: 'GET',
870
+ path: '/admin/config/ai',
871
+ async handler(request, h) {
872
+ const errorLog = ((await llmPreProcess.getErrorLog()) || []).map(entry => {
873
+ if (entry.error && typeof entry.error === 'string') {
874
+ entry.error = entry.error
875
+ .replace(/\r?\n/g, '\n')
876
+ .replace(/^\s+at\s+.*$/gm, '')
877
+ .replace(/\n+/g, '\n')
878
+ .trim()
879
+ .replace(/(evalmachine.<anonymous>:)(\d+)/, (o, p, n) => p + (Number(n) - 1));
880
+ }
881
+ return entry;
882
+ });
883
+
884
+ const values = {
885
+ generateEmailSummary: (await settings.get('generateEmailSummary')) || false,
886
+ openAiPrompt: ((await settings.get('openAiPrompt')) || '').toString(),
887
+ contentFnJson: JSON.stringify(
888
+ ((await settings.get(`openAiPreProcessingFn`)) || '').toString() ||
889
+ `// Pass all emails
890
+ return true;`
891
+ ),
892
+ openAiAPIUrl: ((await settings.get('openAiAPIUrl')) || '').toString(),
893
+ openAiTemperature: ((await settings.get('openAiTemperature')) || '').toString(),
894
+ openAiTopP: ((await settings.get('openAiTopP')) || '').toString(),
895
+ openAiMaxTokens: ((await settings.get('openAiMaxTokens')) || '').toString()
896
+ };
897
+
898
+ if (!values.openAiPrompt.trim()) {
899
+ values.openAiPrompt = await getDefaultPrompt();
900
+ }
901
+
902
+ let hasOpenAiAPIKey = !!(await settings.get('openAiAPIKey'));
903
+ let openAiModel = await settings.get('openAiModel');
904
+ let openAiError = await getOpenAiError(request.app.gt);
905
+
906
+ return h.view(
907
+ 'config/ai',
908
+ {
909
+ pageTitle: 'AI Processing',
910
+ menuConfig: true,
911
+ menuConfigAi: true,
912
+ errorLog,
913
+ defaultPromptJson: JSON.stringify({ prompt: await getDefaultPrompt() }),
914
+ values,
915
+ hasOpenAiAPIKey,
916
+ openAiError,
917
+ openAiModels: await getOpenAiModels(OPEN_AI_MODELS, openAiModel),
918
+ scriptEnvJson: JSON.stringify((await settings.get('scriptEnv')) || '{}'),
919
+ examplePayloadsJson: JSON.stringify(
920
+ (await getExampleDocumentsPayloads()).map(entry =>
921
+ Object.assign({}, entry, { summary: undefined, riskAssessment: undefined, preview: undefined })
922
+ )
923
+ )
924
+ },
925
+ { layout: 'app' }
926
+ );
927
+ }
928
+ });
929
+
930
+ server.route({
931
+ method: 'POST',
932
+ path: '/admin/config/ai',
933
+ async handler(request, h) {
934
+ try {
935
+ let contentFn;
936
+ try {
937
+ if (request.payload.contentFnJson === '') {
938
+ contentFn = null;
939
+ } else {
940
+ contentFn = JSON.parse(request.payload.contentFnJson);
941
+ if (typeof contentFn !== 'string') {
942
+ throw new Error('Invalid Format');
943
+ }
944
+ }
945
+ } catch (err) {
946
+ err.details = { contentFnJson: 'Invalid JSON' };
947
+ throw err;
948
+ }
949
+
950
+ let data = {
951
+ generateEmailSummary: request.payload.generateEmailSummary,
952
+ openAiModel: request.payload.openAiModel,
953
+ openAiAPIUrl: request.payload.openAiAPIUrl,
954
+ openAiPrompt: (request.payload.openAiPrompt || '').toString(),
955
+ openAiPreProcessingFn: contentFn,
956
+ openAiTemperature: request.payload.openAiTemperature,
957
+ openAiTopP: request.payload.openAiTopP,
958
+ openAiMaxTokens: request.payload.openAiMaxTokens
959
+ };
960
+
961
+ let defaultUserPrompt = await getDefaultPrompt();
962
+ if (!data.openAiPrompt.trim() || data.openAiPrompt.trim() === defaultUserPrompt.trim()) {
963
+ data.openAiPrompt = '';
964
+ }
965
+
966
+ if (typeof request.payload.openAiAPIKey === 'string') {
967
+ data.openAiAPIKey = request.payload.openAiAPIKey;
968
+ }
969
+
970
+ if (typeof request.payload.openAiAPIUrl === 'string') {
971
+ data.openAiAPIUrl = request.payload.openAiAPIUrl;
972
+ }
973
+
974
+ for (let key of Object.keys(data)) {
975
+ await settings.set(key, data[key]);
976
+ }
977
+
978
+ await request.flash({ type: 'info', message: `Configuration updated` });
979
+ return h.redirect('/admin/config/ai');
980
+ } catch (err) {
981
+ await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
982
+ request.logger.error({ msg: 'Failed to update configuration', err });
983
+
984
+ let hasOpenAiAPIKey = !!(await settings.get('openAiAPIKey'));
985
+ let openAiError = await getOpenAiError(request.app.gt);
986
+
987
+ const errorLog = ((await llmPreProcess.getErrorLog()) || []).map(entry => {
988
+ if (entry.error && typeof entry.error === 'string') {
989
+ entry.error = entry.error
990
+ .replace(/\r?\n/g, '\n')
991
+ .replace(/^\s+at\s+.*$/gm, '')
992
+ .replace(/\n+/g, '\n')
993
+ .trim()
994
+ .replace(/(evalmachine.<anonymous>:)(\d+)/, (o, p, n) => p + (Number(n) - 1));
995
+ }
996
+ return entry;
997
+ });
998
+
999
+ return h.view(
1000
+ 'config/ai',
1001
+ {
1002
+ pageTitle: 'AI Processing',
1003
+ menuConfig: true,
1004
+ menuConfigAi: true,
1005
+ errorLog,
1006
+ defaultPromptJson: JSON.stringify({ prompt: await getDefaultPrompt() }),
1007
+ hasOpenAiAPIKey,
1008
+ openAiError,
1009
+ openAiModels: await getOpenAiModels(OPEN_AI_MODELS, request.payload.openAiModel),
1010
+ scriptEnvJson: JSON.stringify((await settings.get('scriptEnv')) || '{}'),
1011
+ examplePayloadsJson: JSON.stringify(
1012
+ (await getExampleDocumentsPayloads()).map(entry =>
1013
+ Object.assign({}, entry, { summary: undefined, riskAssessment: undefined, preview: undefined })
1014
+ )
1015
+ )
1016
+ },
1017
+ { layout: 'app' }
1018
+ );
1019
+ }
1020
+ },
1021
+ options: {
1022
+ validate: {
1023
+ options: { stripUnknown: true, abortEarly: false, convert: true },
1024
+ async failAction(request, h, err) {
1025
+ let errors = {};
1026
+ if (err.details) {
1027
+ err.details.forEach(detail => {
1028
+ if (!errors[detail.path]) {
1029
+ errors[detail.path] = detail.message;
1030
+ }
1031
+ });
1032
+ }
1033
+
1034
+ await request.flash({ type: 'danger', message: `Couldn't save settings. Try again.` });
1035
+ request.logger.error({ msg: 'Failed to update configuration', err });
1036
+
1037
+ let hasOpenAiAPIKey = !!(await settings.get('openAiAPIKey'));
1038
+ let openAiError = await getOpenAiError(request.app.gt);
1039
+
1040
+ const errorLog = ((await llmPreProcess.getErrorLog()) || []).map(entry => {
1041
+ if (entry.error && typeof entry.error === 'string') {
1042
+ entry.error = entry.error
1043
+ .replace(/\r?\n/g, '\n')
1044
+ .replace(/^\s+at\s+.*$/gm, '')
1045
+ .replace(/\n+/g, '\n')
1046
+ .trim()
1047
+ .replace(/(evalmachine.<anonymous>:)(\d+)/, (o, p, n) => p + (Number(n) - 1));
1048
+ }
1049
+ return entry;
1050
+ });
1051
+
1052
+ return h
1053
+ .view(
1054
+ 'config/ai',
1055
+ {
1056
+ pageTitle: 'AI Processing',
1057
+ menuConfig: true,
1058
+ menuConfigAi: true,
1059
+ errorLog,
1060
+ defaultPromptJson: JSON.stringify({ prompt: await getDefaultPrompt() }),
1061
+ hasOpenAiAPIKey,
1062
+ openAiError,
1063
+ openAiModels: await getOpenAiModels(OPEN_AI_MODELS, request.payload.openAiModel),
1064
+ scriptEnvJson: JSON.stringify((await settings.get('scriptEnv')) || '{}'),
1065
+ examplePayloadsJson: JSON.stringify(
1066
+ (await getExampleDocumentsPayloads()).map(entry =>
1067
+ Object.assign({}, entry, { summary: undefined, riskAssessment: undefined, preview: undefined })
1068
+ )
1069
+ ),
1070
+ errors
1071
+ },
1072
+ { layout: 'app' }
1073
+ )
1074
+ .takeover();
1075
+ },
1076
+ payload: Joi.object({
1077
+ generateEmailSummary: settingsSchema.generateEmailSummary.default(false),
1078
+ openAiAPIKey: settingsSchema.openAiAPIKey.empty(''),
1079
+ openAiModel: settingsSchema.openAiModel.empty(''),
1080
+ openAiAPIUrl: settingsSchema.openAiAPIUrl.default(''),
1081
+ openAiPrompt: settingsSchema.openAiPrompt.default(''),
1082
+ contentFnJson: Joi.string()
1083
+ .max(1024 * 1024)
1084
+ .default('')
1085
+ .allow('')
1086
+ .trim(),
1087
+ openAiTemperature: settingsSchema.openAiTemperature.default(''),
1088
+ openAiTopP: settingsSchema.openAiTopP.default(''),
1089
+ openAiMaxTokens: settingsSchema.openAiMaxTokens.default('')
1090
+ })
1091
+ }
1092
+ }
1093
+ });
1094
+
1095
+ // AI test prompt route
1096
+ server.route({
1097
+ method: 'POST',
1098
+ path: '/admin/config/ai/test-prompt',
1099
+ async handler(request) {
1100
+ try {
1101
+ request.logger.info({ msg: 'Prompt test' });
1102
+
1103
+ const parsed = await simpleParser(Buffer.from(request.payload.emailFile, 'base64'));
1104
+
1105
+ const response = {};
1106
+ response.summary = await call({
1107
+ cmd: 'generateSummary',
1108
+ data: {
1109
+ message: {
1110
+ headers: parsed.headerLines.map(header => libmime.decodeHeader(header.line)),
1111
+ attachments: parsed.attachments,
1112
+ html: parsed.html,
1113
+ text: parsed.text
1114
+ },
1115
+ openAiAPIKey: request.payload.openAiAPIKey,
1116
+ openAiModel: request.payload.openAiModel,
1117
+ openAiAPIUrl: request.payload.openAiAPIUrl,
1118
+ openAiPrompt: request.payload.openAiPrompt,
1119
+ openAiTemperature: request.payload.openAiTemperature,
1120
+ openAiTopP: request.payload.openAiTopP,
1121
+ openAiMaxTokens: request.payload.openAiMaxTokens
1122
+ },
1123
+ timeout: 2 * 60 * 1000
1124
+ });
1125
+
1126
+ for (let key of Object.keys(response.summary)) {
1127
+ if (key.charAt(0) === '_' || response.summary[key] === '') {
1128
+ delete response.summary[key];
1129
+ }
1130
+ if (key === 'riskAssessment') {
1131
+ response.riskAssessment = response.summary.riskAssessment;
1132
+ delete response.summary.riskAssessment;
1133
+ }
1134
+ }
1135
+
1136
+ return { success: true, response };
1137
+ } catch (err) {
1138
+ request.logger.error({ msg: 'Failed to test prompt', err });
1139
+ return { success: false, error: err.message };
1140
+ }
1141
+ },
1142
+ options: {
1143
+ payload: { maxBytes: MAX_BODY_SIZE, timeout: MAX_PAYLOAD_TIMEOUT },
1144
+ validate: {
1145
+ options: { stripUnknown: true, abortEarly: false, convert: true },
1146
+ failAction,
1147
+ payload: Joi.object({
1148
+ emailFile: Joi.string().base64({ paddingRequired: false }).required(),
1149
+ openAiAPIKey: settingsSchema.openAiAPIKey.empty(''),
1150
+ openAiModel: settingsSchema.openAiModel.empty(''),
1151
+ openAiAPIUrl: settingsSchema.openAiAPIUrl.default(''),
1152
+ openAiPrompt: settingsSchema.openAiPrompt.default(''),
1153
+ openAiTemperature: settingsSchema.openAiTemperature.empty(''),
1154
+ openAiTopP: settingsSchema.openAiTopP.empty(''),
1155
+ openAiMaxTokens: settingsSchema.openAiMaxTokens.empty('')
1156
+ })
1157
+ }
1158
+ }
1159
+ });
1160
+
1161
+ // AI reload models route
1162
+ server.route({
1163
+ method: 'POST',
1164
+ path: '/admin/config/ai/reload-models',
1165
+ async handler(request) {
1166
+ try {
1167
+ request.logger.info({ msg: 'Reload models' });
1168
+
1169
+ const { models } = await call({
1170
+ cmd: 'openAiListModels',
1171
+ data: {
1172
+ openAiAPIKey: request.payload.openAiAPIKey,
1173
+ openAiAPIUrl: request.payload.openAiAPIUrl
1174
+ },
1175
+ timeout: 2 * 60 * 1000
1176
+ });
1177
+
1178
+ if (models && models.length) {
1179
+ await settings.set('openAiModels', models);
1180
+ }
1181
+
1182
+ return { success: true, models };
1183
+ } catch (err) {
1184
+ request.logger.error({ msg: 'Failed reloading OpenAI models', err });
1185
+ return { success: false, error: err.message };
1186
+ }
1187
+ },
1188
+ options: {
1189
+ validate: {
1190
+ options: { stripUnknown: true, abortEarly: false, convert: true },
1191
+ failAction,
1192
+ payload: Joi.object({
1193
+ openAiAPIKey: settingsSchema.openAiAPIKey.empty(''),
1194
+ openAiAPIUrl: settingsSchema.openAiAPIUrl.default('')
1195
+ })
1196
+ }
1197
+ }
1198
+ });
1199
+
1200
+ // Browser config route
1201
+ server.route({
1202
+ method: 'POST',
1203
+ path: '/admin/config/browser',
1204
+ async handler(request) {
1205
+ for (let key of ['serviceUrl', 'language', 'timezone']) {
1206
+ if (request.payload[key]) {
1207
+ let existingValue = await settings.get(key);
1208
+ if (existingValue === null) {
1209
+ await settings.set(key, request.payload[key]);
1210
+ }
1211
+ }
1212
+ }
1213
+ return { success: true };
1214
+ },
1215
+ options: {
1216
+ validate: {
1217
+ options: { stripUnknown: true, abortEarly: false, convert: true },
1218
+ failAction,
1219
+ payload: Joi.object({
1220
+ serviceUrl: settingsSchema.serviceUrl.empty('').allow(false),
1221
+ language: Joi.string()
1222
+ .empty('')
1223
+ .lowercase()
1224
+ .regex(/^[a-z0-9]{1,5}([-_][a-z0-9]{1,15})?$/)
1225
+ .allow(false),
1226
+ timezone: Joi.string().empty('').allow(false).max(255)
1227
+ })
1228
+ }
1229
+ }
1230
+ });
1231
+ }
1232
+
1233
+ module.exports = init;