emailengine-app 2.61.1 → 2.61.3

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 +17 -0
  2. package/data/google-crawlers.json +1 -1
  3. package/lib/account/account-state.js +248 -0
  4. package/lib/account.js +45 -193
  5. package/lib/api-routes/account-routes.js +1023 -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 +10 -10
  35. package/sbom.json +1 -1
  36. package/static/js/app.js +5 -5
  37. package/static/licenses.html +79 -19
  38. package/translations/de.mo +0 -0
  39. package/translations/de.po +97 -86
  40. package/translations/en.mo +0 -0
  41. package/translations/en.po +80 -75
  42. package/translations/et.mo +0 -0
  43. package/translations/et.po +96 -86
  44. package/translations/fr.mo +0 -0
  45. package/translations/fr.po +97 -86
  46. package/translations/ja.mo +0 -0
  47. package/translations/ja.po +96 -86
  48. package/translations/messages.pot +105 -91
  49. package/translations/nl.mo +0 -0
  50. package/translations/nl.po +98 -86
  51. package/translations/pl.mo +0 -0
  52. package/translations/pl.po +96 -86
  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,2367 @@
1
+ 'use strict';
2
+
3
+ const Boom = require('@hapi/boom');
4
+ const Joi = require('joi');
5
+
6
+ const settings = require('../settings');
7
+ const tokens = require('../tokens');
8
+ const { redis } = require('../db');
9
+ const getSecret = require('../get-secret');
10
+ const { failAction, verifyAccountInfo } = require('../tools');
11
+ const { templateSchemas, accountIdSchema } = require('../schemas');
12
+ const { Account } = require('../account');
13
+ const { Gateway } = require('../gateway');
14
+ const { templates } = require('../templates');
15
+ const { webhooks } = require('../webhooks');
16
+ const consts = require('../consts');
17
+ const wellKnownServices = require('nodemailer/lib/well-known/services.json');
18
+ const exampleWebhookPayloads = require('../payload-examples-webhooks.json');
19
+
20
+ const { DEFAULT_PAGE_SIZE } = consts;
21
+
22
+ const notificationTypes = Object.keys(consts)
23
+ .map(key => {
24
+ if (/_NOTIFY$/.test(key)) {
25
+ return key.replace(/_NOTIFY$/, '');
26
+ }
27
+ return false;
28
+ })
29
+ .filter(key => key)
30
+ .map(key => ({
31
+ key,
32
+ name: consts[`${key}_NOTIFY`],
33
+ description: consts[`${key}_DESCRIPTION`]
34
+ }));
35
+
36
+ const CODE_FORMATS = [
37
+ {
38
+ format: 'html',
39
+ name: 'HTML'
40
+ },
41
+ {
42
+ format: 'markdown',
43
+ name: 'Markdown'
44
+ }
45
+ ];
46
+
47
+ async function getExampleWebhookPayloads() {
48
+ let serviceUrl = await settings.get('serviceUrl');
49
+ let date = new Date().toISOString();
50
+
51
+ let examplePayloads = structuredClone(exampleWebhookPayloads);
52
+
53
+ examplePayloads.forEach(payload => {
54
+ if (payload && payload.content) {
55
+ if (typeof payload.content.serviceUrl === 'string') {
56
+ payload.content.serviceUrl = serviceUrl;
57
+ }
58
+
59
+ if (typeof payload.content.date === 'string') {
60
+ payload.content.date = date;
61
+ }
62
+
63
+ if (payload.content.data && typeof payload.content.data.date === 'string') {
64
+ payload.content.data.date = date;
65
+ }
66
+
67
+ if (payload.content.data && typeof payload.content.data.created === 'string') {
68
+ payload.content.data.created = date;
69
+ }
70
+ }
71
+ });
72
+ return examplePayloads;
73
+ }
74
+
75
+ function init(args) {
76
+ const { server, call } = args;
77
+
78
+ // Webhook routes
79
+
80
+ server.route({
81
+ method: 'GET',
82
+ path: '/admin/webhooks',
83
+ async handler(request, h) {
84
+ let data = await webhooks.list(request.query.page - 1, request.query.pageSize);
85
+
86
+ let nextPage = false;
87
+ let prevPage = false;
88
+
89
+ if (request.query.account) {
90
+ let accountObject = new Account({ redis, account: request.query.account });
91
+ data.account = await accountObject.loadAccountData();
92
+ }
93
+
94
+ let getPagingUrl = page => {
95
+ let url = new URL(`admin/webhooks`, 'http://localhost');
96
+
97
+ if (page) {
98
+ url.searchParams.append('page', page);
99
+ }
100
+
101
+ if (request.query.pageSize !== DEFAULT_PAGE_SIZE) {
102
+ url.searchParams.append('pageSize', request.query.pageSize);
103
+ }
104
+
105
+ return url.pathname + url.search;
106
+ };
107
+
108
+ if (data.pages > data.page + 1) {
109
+ nextPage = getPagingUrl(data.page + 2);
110
+ }
111
+
112
+ if (data.page > 0) {
113
+ prevPage = getPagingUrl(data.page);
114
+ }
115
+
116
+ let newLink = new URL('/admin/webhooks/new', 'http://localhost');
117
+
118
+ return h.view(
119
+ 'webhooks/index',
120
+ {
121
+ pageTitle: 'Webhook Routing',
122
+ menuWebhooks: true,
123
+
124
+ newLink: newLink.pathname + newLink.search,
125
+
126
+ showPaging: data.pages > 1,
127
+ nextPage,
128
+ prevPage,
129
+ firstPage: data.page === 0,
130
+ pageLinks: new Array(data.pages || 1).fill(0).map((z, i) => ({
131
+ url: getPagingUrl(i + 1),
132
+ title: i + 1,
133
+ active: i === data.page
134
+ })),
135
+
136
+ webhooksEnabled: await settings.get('webhooksEnabled'),
137
+
138
+ webhooks: data.webhooks
139
+ },
140
+ {
141
+ layout: 'app'
142
+ }
143
+ );
144
+ },
145
+
146
+ options: {
147
+ validate: {
148
+ options: {
149
+ stripUnknown: true,
150
+ abortEarly: false,
151
+ convert: true
152
+ },
153
+
154
+ async failAction(request, h /*, err*/) {
155
+ return h.redirect('/admin/webhooks').takeover();
156
+ },
157
+
158
+ query: Joi.object({
159
+ page: Joi.number().integer().min(1).max(1000000).default(1),
160
+ pageSize: Joi.number().integer().min(1).max(250).default(DEFAULT_PAGE_SIZE)
161
+ })
162
+ }
163
+ }
164
+ });
165
+
166
+ server.route({
167
+ method: 'GET',
168
+ path: '/admin/webhooks/new',
169
+ async handler(request, h) {
170
+ const values = {
171
+ name: '',
172
+ description: '',
173
+
174
+ contentFnJson: JSON.stringify(`/*
175
+ // The following example passes webhooks for new emails that appear in the Inbox of the user "testaccount".
176
+ // NB! Gmail webhooks are always emitted from the "All Mail" folder, not the Inbox, so we need to check both the path and label values.
177
+
178
+ const isInbox = payload.path === 'INBOX' || payload.data?.labels?.includes('\\\\Inbox');
179
+ if (payload.event === 'messageNew' && payload.account === 'testaccount' && isInbox) {
180
+ return true;
181
+ }
182
+ */
183
+
184
+ return true; // pass all`),
185
+ contentMapJson: JSON.stringify(`// By default the output payload is returned unmodified.
186
+
187
+ return payload;`)
188
+ };
189
+
190
+ return h.view(
191
+ 'webhooks/new',
192
+ {
193
+ pageTitle: 'Webhook Routing',
194
+ menuWebhooks: true,
195
+ values,
196
+
197
+ examplePayloadsJson: JSON.stringify(await getExampleWebhookPayloads()),
198
+ notificationTypesJson: JSON.stringify(notificationTypes),
199
+ scriptEnvJson: JSON.stringify((await settings.get('scriptEnv')) || '{}')
200
+ },
201
+ {
202
+ layout: 'app'
203
+ }
204
+ );
205
+ },
206
+
207
+ options: {
208
+ validate: {
209
+ options: {
210
+ stripUnknown: true,
211
+ abortEarly: false,
212
+ convert: true
213
+ },
214
+
215
+ async failAction(request, h /*, err*/) {
216
+ return h.redirect('/admin/webhooks').takeover();
217
+ },
218
+
219
+ query: Joi.object({})
220
+ }
221
+ }
222
+ });
223
+
224
+ server.route({
225
+ method: 'POST',
226
+ path: '/admin/webhooks/new',
227
+ async handler(request, h) {
228
+ let contentFn, contentMap;
229
+ try {
230
+ if (request.payload.contentFnJson === '') {
231
+ contentFn = null;
232
+ } else {
233
+ contentFn = JSON.parse(request.payload.contentFnJson);
234
+ if (typeof contentFn !== 'string') {
235
+ throw new Error('Invalid Format');
236
+ }
237
+ }
238
+ } catch (err) {
239
+ err.details = {
240
+ contentFnJson: 'Invalid JSON'
241
+ };
242
+ throw err;
243
+ }
244
+
245
+ try {
246
+ if (request.payload.contentMapJson === '') {
247
+ contentMap = null;
248
+ } else {
249
+ contentMap = JSON.parse(request.payload.contentMapJson);
250
+ if (typeof contentMap !== 'string') {
251
+ throw new Error('Invalid Format');
252
+ }
253
+ }
254
+ } catch (err) {
255
+ err.details = {
256
+ contentMapJson: 'Invalid JSON'
257
+ };
258
+ throw err;
259
+ }
260
+
261
+ let customHeaders = request.payload.customHeaders
262
+ .split(/[\r\n]+/)
263
+ .map(header => header.trim())
264
+ .filter(header => header)
265
+ .map(line => {
266
+ let sep = line.indexOf(':');
267
+ if (sep >= 0) {
268
+ return {
269
+ key: line.substring(0, sep).trim(),
270
+ value: line.substring(sep + 1).trim()
271
+ };
272
+ }
273
+ return {
274
+ key: line,
275
+ value: ''
276
+ };
277
+ });
278
+
279
+ try {
280
+ let createRequest = await webhooks.create(
281
+ {
282
+ name: request.payload.name,
283
+ description: request.payload.description,
284
+ targetUrl: request.payload.targetUrl,
285
+ enabled: request.payload.enabled,
286
+
287
+ customHeaders
288
+ },
289
+ {
290
+ fn: contentFn,
291
+ map: contentMap
292
+ }
293
+ );
294
+
295
+ await request.flash({ type: 'info', message: `Webhook created` });
296
+ return h.redirect(`/admin/webhooks/webhook/${createRequest.id}`);
297
+ } catch (err) {
298
+ await request.flash({ type: 'danger', message: `Couldn't create webhook. Try again.` });
299
+ request.logger.error({ msg: 'Failed to create webhook routing', err });
300
+
301
+ return h.view(
302
+ 'webhooks/new',
303
+ {
304
+ pageTitle: 'Webhook Routing',
305
+ menuWebhooks: true,
306
+ errors: err.details,
307
+
308
+ examplePayloadsJson: JSON.stringify(await getExampleWebhookPayloads()),
309
+ notificationTypesJson: JSON.stringify(notificationTypes),
310
+ scriptEnvJson: JSON.stringify((await settings.get('scriptEnv')) || '{}')
311
+ },
312
+ {
313
+ layout: 'app'
314
+ }
315
+ );
316
+ }
317
+ },
318
+ options: {
319
+ validate: {
320
+ options: {
321
+ stripUnknown: true,
322
+ abortEarly: false,
323
+ convert: true
324
+ },
325
+
326
+ async failAction(request, h, err) {
327
+ let errors = {};
328
+
329
+ if (err.details) {
330
+ err.details.forEach(detail => {
331
+ if (!errors[detail.path]) {
332
+ errors[detail.path] = detail.message;
333
+ }
334
+ });
335
+ }
336
+
337
+ await request.flash({ type: 'danger', message: `Couldn't create webhook. Try again.` });
338
+ request.logger.error({ msg: 'Failed to create webhook routing', err });
339
+
340
+ return h
341
+ .view(
342
+ 'templates/new',
343
+ {
344
+ pageTitle: 'Templates',
345
+ menuTemplates: true,
346
+ errors,
347
+
348
+ examplePayloadsJson: JSON.stringify(await getExampleWebhookPayloads()),
349
+ notificationTypesJson: JSON.stringify(notificationTypes)
350
+ },
351
+ {
352
+ layout: 'app'
353
+ }
354
+ )
355
+ .takeover();
356
+ },
357
+
358
+ payload: Joi.object({
359
+ name: Joi.string().max(256).example('Transaction receipt').description('Name of the routing').label('RoutingName').required(),
360
+ description: Joi.string()
361
+ .allow('')
362
+ .max(1024)
363
+ .example('Something about the routing')
364
+ .description('Optional description of the webhook routing')
365
+ .label('RoutingDescription'),
366
+ targetUrl: Joi.string()
367
+ .uri({
368
+ scheme: ['http', 'https'],
369
+ allowRelative: false
370
+ })
371
+ .allow('')
372
+ .default('')
373
+ .example('https://myservice.com/imap/webhooks')
374
+ .description('Webhook target URL'),
375
+ enabled: Joi.boolean()
376
+ .truthy('Y', 'true', '1', 'on')
377
+ .falsy('N', 'false', 0, '')
378
+ .default(false)
379
+ .example(false)
380
+ .description('Is the routing enabled'),
381
+ customHeaders: Joi.string()
382
+ .allow('')
383
+ .trim()
384
+ .max(10 * 1024)
385
+ .description('Custom request headers'),
386
+ contentFnJson: Joi.string()
387
+ .max(1024 * 1024)
388
+ .default('')
389
+ .allow('')
390
+ .trim()
391
+ .description('Filter function'),
392
+ contentMapJson: Joi.string()
393
+ .max(1024 * 1024)
394
+ .default('')
395
+ .allow('')
396
+ .trim()
397
+ .description('Map function')
398
+ })
399
+ }
400
+ }
401
+ });
402
+
403
+ server.route({
404
+ method: 'GET',
405
+ path: '/admin/webhooks/webhook/{webhook}',
406
+ async handler(request, h) {
407
+ let webhook = await webhooks.get(request.params.webhook);
408
+ if (!webhook) {
409
+ let error = Boom.boomify(new Error('Webhook Route was not found.'), { statusCode: 404 });
410
+ throw error;
411
+ }
412
+
413
+ webhook.targetUrlShort = webhook.targetUrl ? new URL(webhook.targetUrl).hostname : false;
414
+
415
+ const errorLog = ((await webhooks.getErrorLog(webhook.id)) || []).map(entry => {
416
+ if (entry.error && typeof entry.error === 'string') {
417
+ entry.error = entry.error
418
+ .replace(/\r?\n/g, '\n')
419
+ .replace(/^\s+at\s+.*$/gm, '')
420
+ .replace(/\n+/g, '\n')
421
+ .trim()
422
+ .replace(/(evalmachine.<anonymous>:)(\d+)/, (o, p, n) => p + (Number(n) - 1));
423
+ }
424
+ return entry;
425
+ });
426
+
427
+ return h.view(
428
+ 'webhooks/webhook',
429
+ {
430
+ pageTitle: 'Webhook Routing',
431
+ menuWebhooks: true,
432
+ webhook,
433
+
434
+ errorLog
435
+ },
436
+ {
437
+ layout: 'app'
438
+ }
439
+ );
440
+ },
441
+
442
+ options: {
443
+ validate: {
444
+ options: {
445
+ stripUnknown: true,
446
+ abortEarly: false,
447
+ convert: true
448
+ },
449
+
450
+ async failAction(request, h /*, err*/) {
451
+ return h.redirect('/admin/webhooks').takeover();
452
+ },
453
+
454
+ params: Joi.object({
455
+ webhook: Joi.string()
456
+ .base64({ paddingRequired: false, urlSafe: true })
457
+ .max(512)
458
+ .example('AAAAAQAACnA')
459
+ .required()
460
+ .description('Webhook Route ID')
461
+ })
462
+ }
463
+ }
464
+ });
465
+
466
+ server.route({
467
+ method: 'GET',
468
+ path: '/admin/webhooks/webhook/{webhook}/edit',
469
+ async handler(request, h) {
470
+ let webhook = await webhooks.get(request.params.webhook);
471
+ if (!webhook) {
472
+ let error = Boom.boomify(new Error('Webhook Route not found.'), { statusCode: 404 });
473
+ throw error;
474
+ }
475
+
476
+ const values = {
477
+ webhook: webhook.id,
478
+ name: webhook.name,
479
+ description: webhook.description,
480
+ targetUrl: webhook.targetUrl,
481
+ enabled: webhook.enabled,
482
+ contentFnJson: JSON.stringify(webhook.content.fn || ''),
483
+ contentMapJson: JSON.stringify(webhook.content.map || ''),
484
+
485
+ customHeaders: []
486
+ .concat(webhook.customHeaders || [])
487
+ .map(entry => `${entry.key}: ${entry.value}`.trim())
488
+ .join('\n')
489
+ };
490
+
491
+ return h.view(
492
+ 'webhooks/edit',
493
+ {
494
+ pageTitle: 'Webhook Routing',
495
+ menuWebhooks: true,
496
+
497
+ webhook,
498
+
499
+ values,
500
+
501
+ examplePayloadsJson: JSON.stringify(await getExampleWebhookPayloads()),
502
+ notificationTypesJson: JSON.stringify(notificationTypes),
503
+ scriptEnvJson: JSON.stringify((await settings.get('scriptEnv')) || '{}')
504
+ },
505
+ {
506
+ layout: 'app'
507
+ }
508
+ );
509
+ },
510
+
511
+ options: {
512
+ validate: {
513
+ options: {
514
+ stripUnknown: true,
515
+ abortEarly: false,
516
+ convert: true
517
+ },
518
+
519
+ async failAction(request, h /*, err*/) {
520
+ return h.redirect('/admin/webhooks').takeover();
521
+ },
522
+
523
+ params: Joi.object({
524
+ webhook: Joi.string()
525
+ .base64({ paddingRequired: false, urlSafe: true })
526
+ .max(512)
527
+ .example('AAAAAQAACnA')
528
+ .required()
529
+ .description('Webhook Route ID')
530
+ })
531
+ }
532
+ }
533
+ });
534
+
535
+ server.route({
536
+ method: 'POST',
537
+ path: '/admin/webhooks/edit',
538
+ async handler(request, h) {
539
+ let contentFn, contentMap;
540
+ try {
541
+ if (request.payload.contentFnJson === '') {
542
+ contentFn = null;
543
+ } else {
544
+ contentFn = JSON.parse(request.payload.contentFnJson);
545
+ if (typeof contentFn !== 'string') {
546
+ throw new Error('Invalid Format');
547
+ }
548
+ }
549
+ } catch (err) {
550
+ err.details = {
551
+ contentFnJson: 'Invalid JSON'
552
+ };
553
+ throw err;
554
+ }
555
+
556
+ try {
557
+ if (request.payload.contentMapJson === '') {
558
+ contentMap = null;
559
+ } else {
560
+ contentMap = JSON.parse(request.payload.contentMapJson);
561
+ if (typeof contentMap !== 'string') {
562
+ throw new Error('Invalid Format');
563
+ }
564
+ }
565
+ } catch (err) {
566
+ err.details = {
567
+ contentMapJson: 'Invalid JSON'
568
+ };
569
+ throw err;
570
+ }
571
+
572
+ let customHeaders = request.payload.customHeaders
573
+ .split(/[\r\n]+/)
574
+ .map(header => header.trim())
575
+ .filter(header => header)
576
+ .map(line => {
577
+ let sep = line.indexOf(':');
578
+ if (sep >= 0) {
579
+ return {
580
+ key: line.substring(0, sep).trim(),
581
+ value: line.substring(sep + 1).trim()
582
+ };
583
+ }
584
+ return {
585
+ key: line,
586
+ value: ''
587
+ };
588
+ });
589
+
590
+ try {
591
+ await webhooks.update(
592
+ request.payload.webhook,
593
+ {
594
+ name: request.payload.name,
595
+ description: request.payload.description,
596
+ targetUrl: request.payload.targetUrl,
597
+ enabled: request.payload.enabled,
598
+
599
+ customHeaders
600
+ },
601
+ {
602
+ fn: contentFn,
603
+ map: contentMap
604
+ }
605
+ );
606
+
607
+ await request.flash({ type: 'info', message: `Webhook saved` });
608
+ return h.redirect(`/admin/webhooks/webhook/${request.payload.webhook}`);
609
+ } catch (err) {
610
+ await request.flash({ type: 'danger', message: `Couldn't save webhook. Try again.` });
611
+ request.logger.error({ msg: 'Failed to update Webhook Route', err });
612
+
613
+ let webhook = await webhooks.get(request.payload.webhook);
614
+ if (!webhook) {
615
+ let error = Boom.boomify(new Error('Webhook Route not found.'), { statusCode: 404 });
616
+ throw error;
617
+ }
618
+
619
+ return h.view(
620
+ 'webhooks/edit',
621
+ {
622
+ pageTitle: 'Webhook Routing',
623
+ menuWebhooks: true,
624
+
625
+ webhook,
626
+
627
+ errors: err.details,
628
+
629
+ examplePayloadsJson: JSON.stringify(await getExampleWebhookPayloads()),
630
+ notificationTypesJson: JSON.stringify(notificationTypes),
631
+ scriptEnvJson: JSON.stringify((await settings.get('scriptEnv')) || '{}')
632
+ },
633
+ {
634
+ layout: 'app'
635
+ }
636
+ );
637
+ }
638
+ },
639
+ options: {
640
+ validate: {
641
+ options: {
642
+ stripUnknown: true,
643
+ abortEarly: false,
644
+ convert: true
645
+ },
646
+
647
+ async failAction(request, h, err) {
648
+ let errors = {};
649
+
650
+ if (err.details) {
651
+ err.details.forEach(detail => {
652
+ if (!errors[detail.path]) {
653
+ errors[detail.path] = detail.message;
654
+ }
655
+ });
656
+ }
657
+
658
+ await request.flash({ type: 'danger', message: `Couldn't save webhook. Try again.` });
659
+ request.logger.error({ msg: 'Failed to update Webhook Route', err });
660
+
661
+ let webhook = await webhooks.get(request.payload.webhook);
662
+ if (!webhook) {
663
+ let error = Boom.boomify(new Error('Webhook Route not found.'), { statusCode: 404 });
664
+ throw error;
665
+ }
666
+
667
+ return h
668
+ .view(
669
+ 'webhooks/edit',
670
+ {
671
+ pageTitle: 'Webhook Routing',
672
+ menuWebhooks: true,
673
+
674
+ webhook,
675
+
676
+ errors,
677
+
678
+ examplePayloadsJson: JSON.stringify(await getExampleWebhookPayloads()),
679
+ notificationTypesJson: JSON.stringify(notificationTypes),
680
+ scriptEnvJson: JSON.stringify((await settings.get('scriptEnv')) || '{}')
681
+ },
682
+ {
683
+ layout: 'app'
684
+ }
685
+ )
686
+ .takeover();
687
+ },
688
+
689
+ payload: Joi.object({
690
+ webhook: Joi.string()
691
+ .base64({ paddingRequired: false, urlSafe: true })
692
+ .max(512)
693
+ .example('AAAAAQAACnA')
694
+ .required()
695
+ .description('Webhook Route ID'),
696
+
697
+ name: Joi.string().max(256).example('Transaction receipt').description('Name of the routing').label('RoutingName').required(),
698
+ description: Joi.string()
699
+ .allow('')
700
+ .max(1024)
701
+ .example('Something about the routing')
702
+ .description('Optional description of the webhook routing')
703
+ .label('RoutingDescription'),
704
+ targetUrl: Joi.string()
705
+ .uri({
706
+ scheme: ['http', 'https'],
707
+ allowRelative: false
708
+ })
709
+ .allow('')
710
+ .default('')
711
+ .example('https://myservice.com/imap/webhooks')
712
+ .description('Webhook target URL'),
713
+ enabled: Joi.boolean()
714
+ .truthy('Y', 'true', '1', 'on')
715
+ .falsy('N', 'false', 0, '')
716
+ .default(false)
717
+ .example(false)
718
+ .description('Is the routing enabled'),
719
+ customHeaders: Joi.string()
720
+ .allow('')
721
+ .trim()
722
+ .max(10 * 1024)
723
+ .description('Custom request headers'),
724
+ contentFnJson: Joi.string()
725
+ .max(1024 * 1024)
726
+ .default('')
727
+ .allow('')
728
+ .trim()
729
+ .description('Filter function'),
730
+ contentMapJson: Joi.string()
731
+ .max(1024 * 1024)
732
+ .default('')
733
+ .allow('')
734
+ .trim()
735
+ .description('Map function')
736
+ })
737
+ }
738
+ }
739
+ });
740
+
741
+ server.route({
742
+ method: 'POST',
743
+ path: '/admin/webhooks/delete',
744
+ async handler(request, h) {
745
+ try {
746
+ await webhooks.del(request.payload.webhook);
747
+
748
+ await request.flash({ type: 'info', message: `Webhook deleted` });
749
+
750
+ let accountWebhooksLink = new URL('/admin/webhooks', 'http://localhost');
751
+
752
+ return h.redirect(accountWebhooksLink.pathname + accountWebhooksLink.search);
753
+ } catch (err) {
754
+ await request.flash({ type: 'danger', message: `Couldn't delete webhook. Try again.` });
755
+ request.logger.error({ msg: 'Failed to delete Webhook Route', err, webhook: request.payload.webhook, remoteAddress: request.app.ip });
756
+ return h.redirect(`/admin/webhooks/webhook/${request.payload.webhook}`);
757
+ }
758
+ },
759
+ options: {
760
+ validate: {
761
+ options: {
762
+ stripUnknown: true,
763
+ abortEarly: false,
764
+ convert: true
765
+ },
766
+
767
+ async failAction(request, h, err) {
768
+ await request.flash({ type: 'danger', message: `Couldn't delete webhook. Try again.` });
769
+ request.logger.error({ msg: 'Failed to delete delete Webhook Route', err });
770
+
771
+ return h.redirect('/admin/webhooks').takeover();
772
+ },
773
+
774
+ payload: Joi.object({
775
+ webhook: Joi.string()
776
+ .base64({ paddingRequired: false, urlSafe: true })
777
+ .max(512)
778
+ .example('AAAAAQAACnA')
779
+ .required()
780
+ .description('Webhook Route ID')
781
+ })
782
+ }
783
+ }
784
+ });
785
+
786
+ // Template routes
787
+
788
+ server.route({
789
+ method: 'GET',
790
+ path: '/admin/templates',
791
+ async handler(request, h) {
792
+ let data = await templates.list(request.query.account, request.query.page - 1, request.query.pageSize);
793
+
794
+ let nextPage = false;
795
+ let prevPage = false;
796
+
797
+ if (request.query.account) {
798
+ let accountObject = new Account({ redis, account: request.query.account });
799
+ data.account = await accountObject.loadAccountData();
800
+ }
801
+
802
+ let getPagingUrl = page => {
803
+ let url = new URL(`admin/templates`, 'http://localhost');
804
+ url.searchParams.append('page', page);
805
+
806
+ if (request.query.account) {
807
+ url.searchParams.append('account', request.query.account);
808
+ }
809
+
810
+ if (request.query.pageSize !== DEFAULT_PAGE_SIZE) {
811
+ url.searchParams.append('pageSize', request.query.pageSize);
812
+ }
813
+
814
+ return url.pathname + url.search;
815
+ };
816
+
817
+ if (data.pages > data.page + 1) {
818
+ nextPage = getPagingUrl(data.page + 2);
819
+ }
820
+
821
+ if (data.page > 0) {
822
+ prevPage = getPagingUrl(data.page);
823
+ }
824
+
825
+ let newLink = new URL('/admin/templates/new', 'http://localhost');
826
+ if (request.query.account) {
827
+ newLink.searchParams.append('account', request.query.account);
828
+ }
829
+
830
+ return h.view(
831
+ 'templates/index',
832
+ {
833
+ pageTitle: 'Templates',
834
+ menuTemplates: true,
835
+
836
+ account: data.account,
837
+ newLink: newLink.pathname + newLink.search,
838
+
839
+ showPaging: data.pages > 1,
840
+ nextPage,
841
+ prevPage,
842
+ firstPage: data.page === 0,
843
+ pageLinks: new Array(data.pages || 1).fill(0).map((z, i) => ({
844
+ url: getPagingUrl(i + 1),
845
+ title: i + 1,
846
+ active: i === data.page
847
+ })),
848
+
849
+ templates: data.templates
850
+ },
851
+ {
852
+ layout: 'app'
853
+ }
854
+ );
855
+ },
856
+
857
+ options: {
858
+ validate: {
859
+ options: {
860
+ stripUnknown: true,
861
+ abortEarly: false,
862
+ convert: true
863
+ },
864
+
865
+ async failAction(request, h /*, err*/) {
866
+ return h.redirect('/admin/templates').takeover();
867
+ },
868
+
869
+ query: Joi.object({
870
+ account: accountIdSchema.default(null),
871
+ page: Joi.number().integer().min(1).max(1000000).default(1),
872
+ pageSize: Joi.number().integer().min(1).max(250).default(DEFAULT_PAGE_SIZE)
873
+ })
874
+ }
875
+ }
876
+ });
877
+
878
+ server.route({
879
+ method: 'GET',
880
+ path: '/admin/templates/template/{template}',
881
+ async handler(request, h) {
882
+ let template = await templates.get(request.params.template);
883
+ if (!template) {
884
+ let error = Boom.boomify(new Error('Template not found.'), { statusCode: 404 });
885
+ throw error;
886
+ }
887
+
888
+ let account;
889
+ if (template.account) {
890
+ let accountObject = new Account({ redis, account: template.account });
891
+ account = await accountObject.loadAccountData();
892
+ }
893
+
894
+ let accountTemplatesLink = new URL('/admin/templates', 'http://localhost');
895
+ if (account) {
896
+ accountTemplatesLink.searchParams.append('account', account.account);
897
+ }
898
+
899
+ return h.view(
900
+ 'templates/template',
901
+ {
902
+ pageTitle: 'Templates',
903
+ menuTemplates: true,
904
+
905
+ account,
906
+
907
+ accountTemplatesLink: accountTemplatesLink.pathname + accountTemplatesLink.search,
908
+
909
+ format: CODE_FORMATS.find(entry => entry.format === template.format),
910
+
911
+ template
912
+ },
913
+ {
914
+ layout: 'app'
915
+ }
916
+ );
917
+ },
918
+
919
+ options: {
920
+ validate: {
921
+ options: {
922
+ stripUnknown: true,
923
+ abortEarly: false,
924
+ convert: true
925
+ },
926
+
927
+ async failAction(request, h /*, err*/) {
928
+ return h.redirect('/admin/templates').takeover();
929
+ },
930
+
931
+ params: Joi.object({
932
+ template: Joi.string()
933
+ .base64({ paddingRequired: false, urlSafe: true })
934
+ .max(512)
935
+ .example('AAAAAQAACnA')
936
+ .required()
937
+ .description('Template ID')
938
+ })
939
+ }
940
+ }
941
+ });
942
+
943
+ server.route({
944
+ method: 'GET',
945
+ path: '/admin/templates/template/{template}/edit',
946
+ async handler(request, h) {
947
+ let template = await templates.get(request.params.template);
948
+ if (!template) {
949
+ let error = Boom.boomify(new Error('Template not found.'), { statusCode: 404 });
950
+ throw error;
951
+ }
952
+
953
+ let account;
954
+ if (template.account) {
955
+ let accountObject = new Account({ redis, account: template.account });
956
+ account = await accountObject.loadAccountData();
957
+ }
958
+
959
+ let accountTemplatesLink = new URL('/admin/templates', 'http://localhost');
960
+ if (account) {
961
+ accountTemplatesLink.searchParams.append('account', account.account);
962
+ }
963
+
964
+ const values = {
965
+ template: template.id,
966
+ name: template.name,
967
+ description: template.description,
968
+ subject: template.content.subject,
969
+ format: template.format,
970
+ previewText: template.content.previewText
971
+ };
972
+
973
+ return h.view(
974
+ 'templates/edit',
975
+ {
976
+ pageTitle: 'Templates',
977
+ menuTemplates: true,
978
+
979
+ account,
980
+
981
+ accountTemplatesLink: accountTemplatesLink.pathname + accountTemplatesLink.search,
982
+
983
+ template,
984
+
985
+ formats: CODE_FORMATS.map(format => Object.assign({ selected: format.format === values.format }, format)),
986
+
987
+ values,
988
+
989
+ contentHtmlJson: JSON.stringify(template.content.html || ''),
990
+ contentTextJson: JSON.stringify(template.content.text || '')
991
+ },
992
+ {
993
+ layout: 'app'
994
+ }
995
+ );
996
+ },
997
+
998
+ options: {
999
+ validate: {
1000
+ options: {
1001
+ stripUnknown: true,
1002
+ abortEarly: false,
1003
+ convert: true
1004
+ },
1005
+
1006
+ async failAction(request, h /*, err*/) {
1007
+ return h.redirect('/admin/templates').takeover();
1008
+ },
1009
+
1010
+ params: Joi.object({
1011
+ template: Joi.string()
1012
+ .base64({ paddingRequired: false, urlSafe: true })
1013
+ .max(512)
1014
+ .example('AAAAAQAACnA')
1015
+ .required()
1016
+ .description('Template ID')
1017
+ })
1018
+ }
1019
+ }
1020
+ });
1021
+
1022
+ server.route({
1023
+ method: 'POST',
1024
+ path: '/admin/templates/edit',
1025
+ async handler(request, h) {
1026
+ try {
1027
+ await templates.update(
1028
+ request.payload.template,
1029
+ {
1030
+ name: request.payload.name,
1031
+ description: request.payload.description,
1032
+ format: request.payload.format
1033
+ },
1034
+ {
1035
+ subject: request.payload.subject,
1036
+ html: request.payload.contentHtml,
1037
+ text: request.payload.contentText,
1038
+ previewText: request.payload.previewText
1039
+ }
1040
+ );
1041
+
1042
+ await request.flash({ type: 'info', message: `Template saved` });
1043
+ return h.redirect(`/admin/templates/template/${request.payload.template}`);
1044
+ } catch (err) {
1045
+ await request.flash({ type: 'danger', message: `Couldn't save template. Try again.` });
1046
+ request.logger.error({ msg: 'Failed to update template', err });
1047
+
1048
+ let template = await templates.get(request.payload.template);
1049
+ if (!template) {
1050
+ let error = Boom.boomify(new Error('Template not found.'), { statusCode: 404 });
1051
+ throw error;
1052
+ }
1053
+
1054
+ let account;
1055
+ if (template.account) {
1056
+ let accountObject = new Account({ redis, account: template.account });
1057
+ account = await accountObject.loadAccountData();
1058
+ }
1059
+
1060
+ let accountTemplatesLink = new URL('/admin/templates', 'http://localhost');
1061
+ if (account) {
1062
+ accountTemplatesLink.searchParams.append('account', account.account);
1063
+ }
1064
+
1065
+ return h.view(
1066
+ 'templates/edit',
1067
+ {
1068
+ pageTitle: 'Templates',
1069
+ menuTemplates: true,
1070
+
1071
+ account,
1072
+
1073
+ accountTemplatesLink: accountTemplatesLink.pathname + accountTemplatesLink.search,
1074
+
1075
+ template,
1076
+
1077
+ formats: CODE_FORMATS.map(format => Object.assign({ selected: format.format === request.payload.format }, format)),
1078
+
1079
+ errors: err.details,
1080
+
1081
+ contentHtmlJson: JSON.stringify(request.payload.contentHtml || ''),
1082
+ contentTextJson: JSON.stringify(request.payload.contentText || '')
1083
+ },
1084
+ {
1085
+ layout: 'app'
1086
+ }
1087
+ );
1088
+ }
1089
+ },
1090
+ options: {
1091
+ validate: {
1092
+ options: {
1093
+ stripUnknown: true,
1094
+ abortEarly: false,
1095
+ convert: true
1096
+ },
1097
+
1098
+ async failAction(request, h, err) {
1099
+ let errors = {};
1100
+
1101
+ if (err.details) {
1102
+ err.details.forEach(detail => {
1103
+ if (!errors[detail.path]) {
1104
+ errors[detail.path] = detail.message;
1105
+ }
1106
+ });
1107
+ }
1108
+
1109
+ await request.flash({ type: 'danger', message: `Couldn't save template. Try again.` });
1110
+ request.logger.error({ msg: 'Failed to update template', err });
1111
+
1112
+ let template = await templates.get(request.payload.template);
1113
+ if (!template) {
1114
+ let error = Boom.boomify(new Error('Template not found.'), { statusCode: 404 });
1115
+ throw error;
1116
+ }
1117
+
1118
+ let account;
1119
+ if (template.account) {
1120
+ let accountObject = new Account({ redis, account: template.account });
1121
+ account = await accountObject.loadAccountData();
1122
+ }
1123
+
1124
+ let accountTemplatesLink = new URL('/admin/templates', 'http://localhost');
1125
+ if (account) {
1126
+ accountTemplatesLink.searchParams.append('account', account.account);
1127
+ }
1128
+
1129
+ return h
1130
+ .view(
1131
+ 'templates/edit',
1132
+ {
1133
+ pageTitle: 'Templates',
1134
+ menuTemplates: true,
1135
+
1136
+ account,
1137
+
1138
+ accountTemplatesLink: accountTemplatesLink.pathname + accountTemplatesLink.search,
1139
+
1140
+ template,
1141
+
1142
+ formats: CODE_FORMATS.map(format => Object.assign({ selected: format.format === request.payload.format }, format)),
1143
+
1144
+ errors,
1145
+
1146
+ contentHtmlJson: JSON.stringify(request.payload.contentHtml || ''),
1147
+ contentTextJson: JSON.stringify(request.payload.contentText || '')
1148
+ },
1149
+ {
1150
+ layout: 'app'
1151
+ }
1152
+ )
1153
+ .takeover();
1154
+ },
1155
+
1156
+ payload: Joi.object({
1157
+ template: Joi.string()
1158
+ .base64({ paddingRequired: false, urlSafe: true })
1159
+ .max(512)
1160
+ .example('AAAAAQAACnA')
1161
+ .required()
1162
+ .description('Template ID'),
1163
+
1164
+ name: Joi.string().max(256).example('Transaction receipt').description('Name of the template').label('TemplateName').required(),
1165
+ description: Joi.string()
1166
+ .allow('')
1167
+ .max(1024)
1168
+ .example('Something about the template')
1169
+ .description('Optional description of the template')
1170
+ .label('TemplateDescription'),
1171
+ format: Joi.string().valid('html', 'markdown').default('html').description('Markup language for HTML ("html" or "markdown")'),
1172
+ subject: templateSchemas.subject,
1173
+ contentText: templateSchemas.text,
1174
+ contentHtml: templateSchemas.html,
1175
+ previewText: templateSchemas.previewText
1176
+ })
1177
+ }
1178
+ }
1179
+ });
1180
+
1181
+ server.route({
1182
+ method: 'GET',
1183
+ path: '/admin/templates/new',
1184
+ async handler(request, h) {
1185
+ let account;
1186
+ if (request.query.account) {
1187
+ let accountObject = new Account({ redis, account: request.query.account });
1188
+ account = await accountObject.loadAccountData();
1189
+ }
1190
+
1191
+ let accountTemplatesLink = new URL('/admin/templates', 'http://localhost');
1192
+ if (account) {
1193
+ accountTemplatesLink.searchParams.append('account', account.account);
1194
+ }
1195
+
1196
+ const values = {
1197
+ account: request.query.account,
1198
+ name: '',
1199
+ description: '',
1200
+ subject: '',
1201
+ format: 'html',
1202
+ contentHtml: '',
1203
+ contentText: '',
1204
+ previewText: ''
1205
+ };
1206
+
1207
+ return h.view(
1208
+ 'templates/new',
1209
+ {
1210
+ pageTitle: 'Templates',
1211
+ menuTemplates: true,
1212
+
1213
+ account,
1214
+
1215
+ accountTemplatesLink: accountTemplatesLink.pathname + accountTemplatesLink.search,
1216
+
1217
+ formats: CODE_FORMATS.map(format => Object.assign({ selected: format.format === values.format }, format)),
1218
+
1219
+ values,
1220
+
1221
+ contentHtmlJson: JSON.stringify(''),
1222
+ contentTextJson: JSON.stringify('')
1223
+ },
1224
+ {
1225
+ layout: 'app'
1226
+ }
1227
+ );
1228
+ },
1229
+
1230
+ options: {
1231
+ validate: {
1232
+ options: {
1233
+ stripUnknown: true,
1234
+ abortEarly: false,
1235
+ convert: true
1236
+ },
1237
+
1238
+ async failAction(request, h /*, err*/) {
1239
+ return h.redirect('/admin/templates').takeover();
1240
+ },
1241
+
1242
+ query: Joi.object({
1243
+ account: accountIdSchema.default(null)
1244
+ })
1245
+ }
1246
+ }
1247
+ });
1248
+
1249
+ server.route({
1250
+ method: 'POST',
1251
+ path: '/admin/templates/new',
1252
+ async handler(request, h) {
1253
+ try {
1254
+ let createRequest = await templates.create(
1255
+ request.payload.account,
1256
+ {
1257
+ name: request.payload.name,
1258
+ description: request.payload.description,
1259
+ format: request.payload.format
1260
+ },
1261
+ {
1262
+ subject: request.payload.subject,
1263
+ html: request.payload.contentHtml,
1264
+ text: request.payload.contentText,
1265
+ previewText: request.payload.previewText
1266
+ }
1267
+ );
1268
+
1269
+ await request.flash({ type: 'info', message: `Template created` });
1270
+ return h.redirect(`/admin/templates/template/${createRequest.id}`);
1271
+ } catch (err) {
1272
+ await request.flash({ type: 'danger', message: `Couldn't create template. Try again.` });
1273
+ request.logger.error({ msg: 'Failed to create template', err });
1274
+
1275
+ let account;
1276
+ if (request.payload.account) {
1277
+ let accountObject = new Account({ redis, account: request.payload.account });
1278
+ account = await accountObject.loadAccountData();
1279
+ }
1280
+
1281
+ let accountTemplatesLink = new URL('/admin/templates', 'http://localhost');
1282
+ if (account) {
1283
+ accountTemplatesLink.searchParams.append('account', account.account);
1284
+ }
1285
+
1286
+ return h.view(
1287
+ 'templates/new',
1288
+ {
1289
+ pageTitle: 'Templates',
1290
+ menuTemplates: true,
1291
+
1292
+ account,
1293
+
1294
+ accountTemplatesLink: accountTemplatesLink.pathname + accountTemplatesLink.search,
1295
+
1296
+ formats: CODE_FORMATS.map(format => Object.assign({ selected: format.format === request.payload.format }, format)),
1297
+
1298
+ errors: err.details,
1299
+
1300
+ contentHtmlJson: JSON.stringify(request.payload.contentHtml || ''),
1301
+ contentTextJson: JSON.stringify(request.payload.contentText || '')
1302
+ },
1303
+ {
1304
+ layout: 'app'
1305
+ }
1306
+ );
1307
+ }
1308
+ },
1309
+ options: {
1310
+ validate: {
1311
+ options: {
1312
+ stripUnknown: true,
1313
+ abortEarly: false,
1314
+ convert: true
1315
+ },
1316
+
1317
+ async failAction(request, h, err) {
1318
+ let errors = {};
1319
+
1320
+ if (err.details) {
1321
+ err.details.forEach(detail => {
1322
+ if (!errors[detail.path]) {
1323
+ errors[detail.path] = detail.message;
1324
+ }
1325
+ });
1326
+ }
1327
+
1328
+ await request.flash({ type: 'danger', message: `Couldn't create template. Try again.` });
1329
+ request.logger.error({ msg: 'Failed to create template', err });
1330
+
1331
+ let account;
1332
+ if (request.payload.account) {
1333
+ let accountObject = new Account({ redis, account: request.payload.account });
1334
+ account = await accountObject.loadAccountData();
1335
+ }
1336
+
1337
+ let accountTemplatesLink = new URL('/admin/templates', 'http://localhost');
1338
+ if (account) {
1339
+ accountTemplatesLink.searchParams.append('account', account.account);
1340
+ }
1341
+
1342
+ return h
1343
+ .view(
1344
+ 'templates/new',
1345
+ {
1346
+ pageTitle: 'Templates',
1347
+ menuTemplates: true,
1348
+
1349
+ account,
1350
+
1351
+ accountTemplatesLink: accountTemplatesLink.pathname + accountTemplatesLink.search,
1352
+
1353
+ formats: CODE_FORMATS.map(format => Object.assign({ selected: format.format === request.payload.format }, format)),
1354
+
1355
+ errors,
1356
+
1357
+ contentHtmlJson: JSON.stringify(request.payload.contentHtml || ''),
1358
+ contentTextJson: JSON.stringify(request.payload.contentText || '')
1359
+ },
1360
+ {
1361
+ layout: 'app'
1362
+ }
1363
+ )
1364
+ .takeover();
1365
+ },
1366
+
1367
+ payload: Joi.object({
1368
+ account: accountIdSchema.default(null),
1369
+
1370
+ name: Joi.string().max(256).example('Transaction receipt').description('Name of the template').label('TemplateName').required(),
1371
+ description: Joi.string()
1372
+ .allow('')
1373
+ .max(1024)
1374
+ .example('Something about the template')
1375
+ .description('Optional description of the template')
1376
+ .label('TemplateDescription'),
1377
+ format: Joi.string().valid('html', 'markdown').default('html').description('Markup language for HTML ("html" or "markdown")'),
1378
+ subject: templateSchemas.subject,
1379
+ contentText: templateSchemas.text,
1380
+ contentHtml: templateSchemas.html,
1381
+ previewText: templateSchemas.previewText
1382
+ })
1383
+ }
1384
+ }
1385
+ });
1386
+
1387
+ server.route({
1388
+ method: 'POST',
1389
+ path: '/admin/templates/delete',
1390
+ async handler(request, h) {
1391
+ try {
1392
+ let templateResponse = await templates.del(request.payload.template);
1393
+
1394
+ await request.flash({ type: 'info', message: `Template deleted` });
1395
+
1396
+ let accountTemplatesLink = new URL('/admin/templates', 'http://localhost');
1397
+ if (templateResponse && templateResponse.account) {
1398
+ accountTemplatesLink.searchParams.append('account', templateResponse.account);
1399
+ }
1400
+
1401
+ return h.redirect(accountTemplatesLink.pathname + accountTemplatesLink.search);
1402
+ } catch (err) {
1403
+ await request.flash({ type: 'danger', message: `Couldn't delete template. Try again.` });
1404
+ request.logger.error({ msg: 'Failed to delete the template', err, template: request.payload.template, remoteAddress: request.app.ip });
1405
+ return h.redirect(`/admin/templates/template/${request.payload.template}`);
1406
+ }
1407
+ },
1408
+ options: {
1409
+ validate: {
1410
+ options: {
1411
+ stripUnknown: true,
1412
+ abortEarly: false,
1413
+ convert: true
1414
+ },
1415
+
1416
+ async failAction(request, h, err) {
1417
+ await request.flash({ type: 'danger', message: `Couldn't delete account. Try again.` });
1418
+ request.logger.error({ msg: 'Failed to delete delete the account', err });
1419
+
1420
+ return h.redirect('/admin/templates').takeover();
1421
+ },
1422
+
1423
+ payload: Joi.object({
1424
+ template: Joi.string()
1425
+ .base64({ paddingRequired: false, urlSafe: true })
1426
+ .max(512)
1427
+ .example('AAAAAQAACnA')
1428
+ .required()
1429
+ .description('Template ID')
1430
+ })
1431
+ }
1432
+ }
1433
+ });
1434
+
1435
+ server.route({
1436
+ method: 'POST',
1437
+ path: '/admin/templates/test',
1438
+ async handler(request) {
1439
+ try {
1440
+ request.logger.info({ msg: 'Trying to send test message', payload: request.payload });
1441
+
1442
+ let template = await templates.get(request.payload.template);
1443
+ if (!template) {
1444
+ return {
1445
+ error: 'Template was not found'
1446
+ };
1447
+ }
1448
+
1449
+ let accountId = template.account || request.payload.account;
1450
+ if (!accountId) {
1451
+ return { error: 'Account ID not provided' };
1452
+ }
1453
+
1454
+ let accountObject = new Account({ redis, account: accountId, call, secret: await getSecret() });
1455
+
1456
+ let account;
1457
+ try {
1458
+ account = await accountObject.loadAccountData();
1459
+ } catch (err) {
1460
+ return {
1461
+ error: err.message
1462
+ };
1463
+ }
1464
+
1465
+ try {
1466
+ return await accountObject.queueMessage(
1467
+ {
1468
+ account: account.account,
1469
+ template: template.id,
1470
+ from: {
1471
+ name: account.name,
1472
+ address: account.email
1473
+ },
1474
+ to: [{ name: '', address: request.payload.to }],
1475
+ render: {
1476
+ params: request.payload.params || {}
1477
+ },
1478
+ copy: false,
1479
+ deliveryAttempts: 0
1480
+ },
1481
+ { source: 'ui' }
1482
+ );
1483
+ } catch (err) {
1484
+ return {
1485
+ error: err.message
1486
+ };
1487
+ }
1488
+ } catch (err) {
1489
+ request.logger.error({ msg: 'Failed sending test message', err });
1490
+ return {
1491
+ success: false,
1492
+ error: err.message
1493
+ };
1494
+ }
1495
+ },
1496
+ options: {
1497
+ tags: ['test'],
1498
+ validate: {
1499
+ options: {
1500
+ stripUnknown: true,
1501
+ abortEarly: false,
1502
+ convert: true
1503
+ },
1504
+
1505
+ failAction,
1506
+
1507
+ payload: Joi.object({
1508
+ account: accountIdSchema.default(null),
1509
+ template: Joi.string()
1510
+ .base64({ paddingRequired: false, urlSafe: true })
1511
+ .max(512)
1512
+ .example('AAAAAQAACnA')
1513
+ .required()
1514
+ .description('Template ID'),
1515
+ to: Joi.string().email().required().description('Recipient address'),
1516
+ params: Joi.object().description('Optional handlebars values').unknown()
1517
+ })
1518
+ }
1519
+ }
1520
+ });
1521
+
1522
+ // Gateway routes
1523
+
1524
+ server.route({
1525
+ method: 'GET',
1526
+ path: '/admin/gateways',
1527
+ async handler(request, h) {
1528
+ let gatewayObject = new Gateway({ redis });
1529
+
1530
+ let gateways = await gatewayObject.listGateways(request.query.page - 1, request.query.pageSize);
1531
+
1532
+ if (gateways.pages < request.query.page) {
1533
+ request.query.page = gateways.pages;
1534
+ }
1535
+
1536
+ let nextPage = false;
1537
+ let prevPage = false;
1538
+
1539
+ let getPagingUrl = page => {
1540
+ let url = new URL(`admin/gateways`, 'http://localhost');
1541
+ url.searchParams.append('page', page);
1542
+ if (request.query.pageSize !== DEFAULT_PAGE_SIZE) {
1543
+ url.searchParams.append('pageSize', request.query.pageSize);
1544
+ }
1545
+ return url.pathname + url.search;
1546
+ };
1547
+
1548
+ if (gateways.pages > gateways.page + 1) {
1549
+ nextPage = getPagingUrl(gateways.page + 2);
1550
+ }
1551
+
1552
+ if (gateways.page > 0) {
1553
+ prevPage = getPagingUrl(gateways.page);
1554
+ }
1555
+
1556
+ return h.view(
1557
+ 'gateways/index',
1558
+ {
1559
+ pageTitle: 'Email Gateways',
1560
+ menuGateways: true,
1561
+
1562
+ showPaging: gateways.pages > 1,
1563
+ nextPage,
1564
+ prevPage,
1565
+ firstPage: gateways.page === 0,
1566
+ pageLinks: new Array(gateways.pages || 1).fill(0).map((z, i) => ({
1567
+ url: getPagingUrl(i + 1),
1568
+ title: i + 1,
1569
+ active: i === gateways.page
1570
+ })),
1571
+
1572
+ gateways: gateways.gateways.map(entry => {
1573
+ let label = {};
1574
+ if (entry.deliveries && !entry.lastError) {
1575
+ label.type = 'success';
1576
+ label.name = 'Connected';
1577
+ } else if (entry.lastError) {
1578
+ label.type = 'danger';
1579
+ label.name = 'Error';
1580
+ label.error = entry.lastError.response;
1581
+ } else {
1582
+ label.type = 'info';
1583
+ label.name = 'Not used';
1584
+ }
1585
+
1586
+ return Object.assign(entry, {
1587
+ timeStr: entry.lastUse ? entry.lastUse.toISOString() : null,
1588
+ label
1589
+ });
1590
+ })
1591
+ },
1592
+ {
1593
+ layout: 'app'
1594
+ }
1595
+ );
1596
+ },
1597
+
1598
+ options: {
1599
+ validate: {
1600
+ options: {
1601
+ stripUnknown: true,
1602
+ abortEarly: false,
1603
+ convert: true
1604
+ },
1605
+
1606
+ async failAction(request, h /*, err*/) {
1607
+ return h.redirect('/admin/gateways').takeover();
1608
+ },
1609
+
1610
+ query: Joi.object({
1611
+ page: Joi.number().integer().min(1).max(1000000).default(1),
1612
+ pageSize: Joi.number().integer().min(1).max(250).default(DEFAULT_PAGE_SIZE)
1613
+ })
1614
+ }
1615
+ }
1616
+ });
1617
+
1618
+ server.route({
1619
+ method: 'GET',
1620
+ path: '/admin/gateways/new',
1621
+ async handler(request, h) {
1622
+ return h.view(
1623
+ 'gateways/new',
1624
+ {
1625
+ pageTitle: 'Email Gateways',
1626
+ menuGateways: true,
1627
+ wellKnownServices: JSON.stringify(Object.keys(wellKnownServices).map(key => Object.assign({ key }, wellKnownServices[key])))
1628
+ },
1629
+ {
1630
+ layout: 'app'
1631
+ }
1632
+ );
1633
+ }
1634
+ });
1635
+
1636
+ server.route({
1637
+ method: 'GET',
1638
+ path: '/admin/gateways/gateway/{gateway}',
1639
+ async handler(request, h) {
1640
+ let gatewayObject = new Gateway({ gateway: request.params.gateway, redis, secret: await getSecret() });
1641
+ let gatewayData = await gatewayObject.loadGatewayData();
1642
+
1643
+ let label = {};
1644
+ if (gatewayData.deliveries && !gatewayData.lastError) {
1645
+ label.type = 'success';
1646
+ label.name = 'Connected';
1647
+ } else if (gatewayData.lastError) {
1648
+ label.type = 'danger';
1649
+ label.name = 'Error';
1650
+ label.error = gatewayData.lastError.response;
1651
+ } else {
1652
+ label.type = 'info';
1653
+ label.name = 'Not used';
1654
+ }
1655
+
1656
+ return h.view(
1657
+ 'gateways/gateway',
1658
+ {
1659
+ pageTitle: 'Email Gateways',
1660
+ menuGateways: true,
1661
+ wellKnownServices: JSON.stringify(Object.keys(wellKnownServices).map(key => Object.assign({ key }, wellKnownServices[key]))),
1662
+
1663
+ gateway: gatewayData,
1664
+ label
1665
+ },
1666
+ {
1667
+ layout: 'app'
1668
+ }
1669
+ );
1670
+ },
1671
+
1672
+ options: {
1673
+ validate: {
1674
+ options: {
1675
+ stripUnknown: true,
1676
+ abortEarly: false,
1677
+ convert: true
1678
+ },
1679
+
1680
+ async failAction(request, h, err) {
1681
+ await request.flash({ type: 'danger', message: `Invalid gateway request: ${err.message}` });
1682
+ return h.redirect('/admin/gateways').takeover();
1683
+ },
1684
+
1685
+ params: Joi.object({
1686
+ gateway: Joi.string().max(256).required().example('sendgun').description('Gateway ID')
1687
+ })
1688
+ }
1689
+ }
1690
+ });
1691
+
1692
+ server.route({
1693
+ method: 'GET',
1694
+ path: '/admin/gateways/edit/{gateway}',
1695
+ async handler(request, h) {
1696
+ let gatewayObject = new Gateway({ gateway: request.params.gateway, redis, secret: await getSecret() });
1697
+ let gatewayData = await gatewayObject.loadGatewayData();
1698
+
1699
+ let hasSMTPPass = !!gatewayData.pass;
1700
+ delete gatewayData.pass;
1701
+
1702
+ return h.view(
1703
+ 'gateways/edit',
1704
+ {
1705
+ pageTitle: 'Email Gateways',
1706
+ menuGateways: true,
1707
+ wellKnownServices: JSON.stringify(Object.keys(wellKnownServices).map(key => Object.assign({ key }, wellKnownServices[key]))),
1708
+ values: gatewayData,
1709
+ gatewayData,
1710
+ hasSMTPPass
1711
+ },
1712
+ {
1713
+ layout: 'app'
1714
+ }
1715
+ );
1716
+ },
1717
+
1718
+ options: {
1719
+ validate: {
1720
+ options: {
1721
+ stripUnknown: true,
1722
+ abortEarly: false,
1723
+ convert: true
1724
+ },
1725
+
1726
+ async failAction(request, h, err) {
1727
+ await request.flash({ type: 'danger', message: `Invalid gateway request: ${err.message}` });
1728
+ return h.redirect('/admin/gateways').takeover();
1729
+ },
1730
+
1731
+ params: Joi.object({
1732
+ gateway: Joi.string().max(256).required().example('sendgun').description('Gateway ID')
1733
+ })
1734
+ }
1735
+ }
1736
+ });
1737
+
1738
+ server.route({
1739
+ method: 'POST',
1740
+ path: '/admin/gateways/new',
1741
+ async handler(request, h) {
1742
+ try {
1743
+ let gatewayData = {
1744
+ gateway: request.payload.gateway || null,
1745
+ name: request.payload.name || null,
1746
+ host: request.payload.host || null,
1747
+ port: request.payload.port || null,
1748
+ secure: request.payload.secure || null,
1749
+ user: request.payload.user || null,
1750
+ pass: request.payload.pass || null,
1751
+ tls: {}
1752
+ };
1753
+
1754
+ let gatewayObject = new Gateway({ redis, secret: await getSecret() });
1755
+ let result = await gatewayObject.create(gatewayData);
1756
+
1757
+ if (result.state === 'new') {
1758
+ await request.flash({ type: 'success', message: `Added new SMTP gateway`, result });
1759
+ } else {
1760
+ await request.flash({ type: 'success', message: `Updated SMTP gateway`, result });
1761
+ }
1762
+
1763
+ return h.redirect(`/admin/gateways/gateway/${encodeURIComponent(result.gateway)}?state=${result.state}`);
1764
+ } catch (err) {
1765
+ await request.flash({ type: 'danger', message: `Couldn't add gateway. Try again.` });
1766
+ request.logger.error({ msg: 'Failed to add new gateway', err });
1767
+
1768
+ return h.view(
1769
+ 'gateways/new',
1770
+ {
1771
+ pageTitle: 'Email Gateways',
1772
+ menuGateways: true,
1773
+ wellKnownServices: JSON.stringify(Object.keys(wellKnownServices).map(key => Object.assign({ key }, wellKnownServices[key])))
1774
+ },
1775
+ {
1776
+ layout: 'app'
1777
+ }
1778
+ );
1779
+ }
1780
+ },
1781
+
1782
+ options: {
1783
+ validate: {
1784
+ options: {
1785
+ stripUnknown: true,
1786
+ abortEarly: false,
1787
+ convert: true
1788
+ },
1789
+
1790
+ async failAction(request, h, err) {
1791
+ let errors = {};
1792
+
1793
+ if (err.details) {
1794
+ err.details.forEach(detail => {
1795
+ if (!errors[detail.path]) {
1796
+ errors[detail.path] = detail.message;
1797
+ }
1798
+ });
1799
+ }
1800
+
1801
+ await request.flash({ type: 'danger', message: `Couldn't add gateway. Try again.` });
1802
+ request.logger.error({ msg: 'Failed to add new gateway', err });
1803
+
1804
+ return h
1805
+ .view(
1806
+ 'gateways/new',
1807
+ {
1808
+ pageTitle: 'Email Gateways',
1809
+ menuGateways: true,
1810
+ wellKnownServices: JSON.stringify(Object.keys(wellKnownServices).map(key => Object.assign({ key }, wellKnownServices[key]))),
1811
+
1812
+ errors
1813
+ },
1814
+ {
1815
+ layout: 'app'
1816
+ }
1817
+ )
1818
+ .takeover();
1819
+ },
1820
+
1821
+ payload: Joi.object({
1822
+ gateway: Joi.string().empty('').trim().max(256).default(null).example('sendgun').description('Gateway ID').label('Gateway ID'),
1823
+
1824
+ name: Joi.string().empty('').max(256).example('John Smith').description('Account Name').label('Gateway Name').required(),
1825
+
1826
+ user: Joi.string().empty('').trim().max(1024).default(null).label('UserName'),
1827
+ pass: Joi.string().empty('').max(1024).default(null).label('Password'),
1828
+
1829
+ host: Joi.string().hostname().example('smtp.gmail.com').description('Hostname to connect to').label('Hostname').required(),
1830
+ port: Joi.number()
1831
+ .integer()
1832
+ .min(1)
1833
+ .max(64 * 1024)
1834
+ .example(465)
1835
+ .description('Service port number')
1836
+ .label('Port')
1837
+ .required(),
1838
+
1839
+ secure: Joi.boolean()
1840
+ .truthy('Y', 'true', '1', 'on')
1841
+ .falsy('N', 'false', 0, '')
1842
+ .default(false)
1843
+ .example(true)
1844
+ .description('Should connection use TLS. Usually true for port 465')
1845
+ .label('TLS')
1846
+ })
1847
+ }
1848
+ }
1849
+ });
1850
+
1851
+ server.route({
1852
+ method: 'POST',
1853
+ path: '/admin/gateways/edit',
1854
+ async handler(request, h) {
1855
+ try {
1856
+ let gatewayData = {
1857
+ gateway: request.payload.gateway || null,
1858
+ name: request.payload.name || null,
1859
+ host: request.payload.host || null,
1860
+ port: request.payload.port || null,
1861
+ secure: request.payload.secure || null,
1862
+ user: request.payload.user || null
1863
+ };
1864
+
1865
+ if (request.payload.pass) {
1866
+ gatewayData.pass = request.payload.pass;
1867
+ }
1868
+
1869
+ if (!request.payload.user && !request.payload.pass) {
1870
+ gatewayData.pass = null;
1871
+ }
1872
+
1873
+ let gatewayObject = new Gateway({ gateway: request.payload.gateway, redis, secret: await getSecret() });
1874
+ let result = await gatewayObject.update(gatewayData);
1875
+
1876
+ await request.flash({ type: 'success', message: `Updated SMTP gateway`, result });
1877
+
1878
+ return h.redirect(`/admin/gateways/gateway/${encodeURIComponent(result.gateway)}`);
1879
+ } catch (err) {
1880
+ await request.flash({ type: 'danger', message: `Couldn't save gateway. Try again.` });
1881
+ request.logger.error({ msg: 'Failed to update gateway', err });
1882
+
1883
+ let gatewayObject = new Gateway({ gateway: request.payload.gateway, redis, secret: await getSecret() });
1884
+ let gatewayData = await gatewayObject.loadGatewayData();
1885
+
1886
+ let hasSMTPPass = !!gatewayData.pass;
1887
+
1888
+ return h.view(
1889
+ 'gateways/edit',
1890
+ {
1891
+ pageTitle: 'Email Gateways',
1892
+ menuGateways: true,
1893
+ wellKnownServices: JSON.stringify(Object.keys(wellKnownServices).map(key => Object.assign({ key }, wellKnownServices[key]))),
1894
+ hasSMTPPass,
1895
+ gatewayData
1896
+ },
1897
+ {
1898
+ layout: 'app'
1899
+ }
1900
+ );
1901
+ }
1902
+ },
1903
+
1904
+ options: {
1905
+ validate: {
1906
+ options: {
1907
+ stripUnknown: true,
1908
+ abortEarly: false,
1909
+ convert: true
1910
+ },
1911
+
1912
+ async failAction(request, h, err) {
1913
+ let errors = {};
1914
+
1915
+ if (err.details) {
1916
+ err.details.forEach(detail => {
1917
+ if (!errors[detail.path]) {
1918
+ errors[detail.path] = detail.message;
1919
+ }
1920
+ });
1921
+ }
1922
+
1923
+ await request.flash({ type: 'danger', message: `Couldn't save gateway. Try again.` });
1924
+ request.logger.error({ msg: 'Failed to update gateway', err });
1925
+
1926
+ let gatewayObject = new Gateway({ gateway: request.payload.gateway, redis, secret: await getSecret() });
1927
+ let gatewayData = await gatewayObject.loadGatewayData();
1928
+
1929
+ let hasSMTPPass = !!gatewayData.pass;
1930
+
1931
+ return h
1932
+ .view(
1933
+ 'gateways/edit',
1934
+ {
1935
+ pageTitle: 'Email Gateways',
1936
+ menuGateways: true,
1937
+ wellKnownServices: JSON.stringify(Object.keys(wellKnownServices).map(key => Object.assign({ key }, wellKnownServices[key]))),
1938
+ hasSMTPPass,
1939
+ gatewayData,
1940
+
1941
+ errors
1942
+ },
1943
+ {
1944
+ layout: 'app'
1945
+ }
1946
+ )
1947
+ .takeover();
1948
+ },
1949
+
1950
+ payload: Joi.object({
1951
+ gateway: Joi.string().empty('').trim().max(256).default(null).example('sendgun').description('Gateway ID').label('Gateway ID').required(),
1952
+
1953
+ name: Joi.string().empty('').max(256).example('John Smith').description('Account Name').label('Gateway Name').required(),
1954
+
1955
+ user: Joi.string().empty('').trim().max(1024).default(null).label('UserName'),
1956
+ pass: Joi.string().empty('').max(1024).default(null).label('Password'),
1957
+
1958
+ host: Joi.string().hostname().example('smtp.gmail.com').description('Hostname to connect to').label('Hostname').required(),
1959
+ port: Joi.number()
1960
+ .integer()
1961
+ .min(1)
1962
+ .max(64 * 1024)
1963
+ .example(465)
1964
+ .description('Service port number')
1965
+ .label('Port')
1966
+ .required(),
1967
+
1968
+ secure: Joi.boolean()
1969
+ .truthy('Y', 'true', '1', 'on')
1970
+ .falsy('N', 'false', 0, '')
1971
+ .default(false)
1972
+ .example(true)
1973
+ .description('Should connection use TLS. Usually true for port 465')
1974
+ .label('TLS')
1975
+ })
1976
+ }
1977
+ }
1978
+ });
1979
+
1980
+ server.route({
1981
+ method: 'POST',
1982
+ path: '/admin/gateways/test',
1983
+ async handler(request) {
1984
+ let { gateway, host, port, user, pass, secure } = request.payload;
1985
+
1986
+ try {
1987
+ if (user && !pass && gateway) {
1988
+ let gatewayObject = new Gateway({ gateway, redis, secret: await getSecret() });
1989
+ try {
1990
+ let gatewayData = await gatewayObject.loadGatewayData();
1991
+ if (gatewayData) {
1992
+ pass = gatewayData.pass || '';
1993
+ }
1994
+ } catch (err) {
1995
+ // ignore
1996
+ }
1997
+ }
1998
+
1999
+ let accountData = {
2000
+ smtp: {
2001
+ host,
2002
+ port,
2003
+ secure,
2004
+ auth:
2005
+ user || pass
2006
+ ? {
2007
+ user,
2008
+ pass: pass || ''
2009
+ }
2010
+ : false
2011
+ }
2012
+ };
2013
+
2014
+ let verifyResult = await verifyAccountInfo(redis, accountData, request.logger.child({ gateway, action: 'verify-gateway' }));
2015
+
2016
+ if (verifyResult) {
2017
+ if (verifyResult.smtp && verifyResult.smtp.error && verifyResult.smtp.code) {
2018
+ switch (verifyResult.smtp.code) {
2019
+ case 'EDNS':
2020
+ verifyResult.smtp.error = request.app.gt.gettext('Server hostname was not found');
2021
+ break;
2022
+ case 'EAUTH':
2023
+ verifyResult.smtp.error = request.app.gt.gettext('Invalid username or password');
2024
+ break;
2025
+ case 'ESOCKET':
2026
+ if (/openssl/.test(verifyResult.smtp.error)) {
2027
+ verifyResult.smtp.error = request.app.gt.gettext('TLS protocol error');
2028
+ }
2029
+ break;
2030
+ }
2031
+ }
2032
+ }
2033
+
2034
+ return verifyResult.smtp;
2035
+ } catch (err) {
2036
+ request.logger.error({ msg: 'Failed posting request', host, port, user, pass: !!pass, err });
2037
+ return {
2038
+ success: false,
2039
+ error: err.message
2040
+ };
2041
+ }
2042
+ },
2043
+ options: {
2044
+ tags: ['test'],
2045
+ validate: {
2046
+ options: {
2047
+ stripUnknown: true,
2048
+ abortEarly: false,
2049
+ convert: true
2050
+ },
2051
+
2052
+ failAction,
2053
+
2054
+ payload: Joi.object({
2055
+ gateway: Joi.string().empty('').trim().max(256).example('sendgun').description('Gateway ID'),
2056
+ user: Joi.string().empty('').trim().max(1024).label('UserName'),
2057
+ pass: Joi.string().empty('').max(1024).label('Password'),
2058
+ host: Joi.string().hostname().example('smtp.gmail.com').description('Hostname to connect to').label('Hostname'),
2059
+ port: Joi.number()
2060
+ .integer()
2061
+ .min(1)
2062
+ .max(64 * 1024)
2063
+ .example(465)
2064
+ .description('Service port number')
2065
+ .label('Port'),
2066
+ secure: Joi.boolean()
2067
+ .truthy('Y', 'true', '1', 'on')
2068
+ .falsy('N', 'false', 0, '')
2069
+ .default(false)
2070
+ .example(true)
2071
+ .description('Should connection use TLS. Usually true for port 465')
2072
+ .label('TLS')
2073
+ })
2074
+ }
2075
+ }
2076
+ });
2077
+
2078
+ server.route({
2079
+ method: 'POST',
2080
+ path: '/admin/gateways/delete/{gateway}',
2081
+ async handler(request, h) {
2082
+ try {
2083
+ let gatewayObject = new Gateway({ redis, gateway: request.params.gateway, secret: await getSecret() });
2084
+
2085
+ let deleted = await gatewayObject.delete();
2086
+ if (deleted) {
2087
+ await request.flash({ type: 'info', message: `Gateway deleted` });
2088
+ }
2089
+
2090
+ return h.redirect('/admin/gateways');
2091
+ } catch (err) {
2092
+ await request.flash({ type: 'danger', message: `Couldn't delete gateway. Try again.` });
2093
+ request.logger.error({ msg: 'Failed to delete the gateway', err, gateway: request.payload.gateway, remoteAddress: request.app.ip });
2094
+ return h.redirect(`/admin/gateways/${request.params.gateway}`);
2095
+ }
2096
+ },
2097
+ options: {
2098
+ validate: {
2099
+ options: {
2100
+ stripUnknown: true,
2101
+ abortEarly: false,
2102
+ convert: true
2103
+ },
2104
+
2105
+ async failAction(request, h, err) {
2106
+ await request.flash({ type: 'danger', message: `Couldn't delete gateway. Try again.` });
2107
+ request.logger.error({ msg: 'Failed to delete delete the gateway', err });
2108
+
2109
+ return h.redirect('/admin/gateways').takeover();
2110
+ },
2111
+
2112
+ params: Joi.object({
2113
+ gateway: Joi.string().max(256).required().example('sendgun').description('Gateway ID')
2114
+ })
2115
+ }
2116
+ }
2117
+ });
2118
+
2119
+ // Token routes
2120
+
2121
+ server.route({
2122
+ method: 'GET',
2123
+ path: '/admin/tokens',
2124
+ async handler(request, h) {
2125
+ let accountData;
2126
+ if (request.query.account) {
2127
+ let accountObject = new Account({ redis, account: request.query.account });
2128
+ accountData = await accountObject.loadAccountData();
2129
+ }
2130
+
2131
+ const data = await tokens.list(request.query.account, request.query.page - 1, request.query.pageSize);
2132
+
2133
+ data.tokens.forEach(entry => {
2134
+ entry.access = entry.access || {};
2135
+ entry.access.timeStr =
2136
+ entry.access && entry.access.time && typeof entry.access.time.toISOString === 'function' ? entry.access.time.toISOString() : null;
2137
+ entry.scopes = entry.scopes
2138
+ ? entry.scopes.map((scope, i) => ({
2139
+ name: scope === '*' ? 'all scopes' : scope,
2140
+ first: !i
2141
+ }))
2142
+ : false;
2143
+ });
2144
+
2145
+ let nextPage = false;
2146
+ let prevPage = false;
2147
+
2148
+ let getPagingUrl = page => {
2149
+ let url = new URL(`admin/tokens`, 'http://localhost');
2150
+
2151
+ if (page) {
2152
+ url.searchParams.append('page', page);
2153
+ }
2154
+
2155
+ if (request.query.pageSize !== DEFAULT_PAGE_SIZE) {
2156
+ url.searchParams.append('pageSize', request.query.pageSize);
2157
+ }
2158
+
2159
+ return url.pathname + url.search;
2160
+ };
2161
+
2162
+ if (data.pages > data.page + 1) {
2163
+ nextPage = getPagingUrl(data.page + 2);
2164
+ }
2165
+
2166
+ if (data.page > 0) {
2167
+ prevPage = getPagingUrl(data.page);
2168
+ }
2169
+
2170
+ let newLink = new URL('/admin/tokens/new', 'http://localhost');
2171
+ if (request.query.account) {
2172
+ newLink.searchParams.append('account', request.query.account);
2173
+ }
2174
+
2175
+ return h.view(
2176
+ 'tokens/index',
2177
+ {
2178
+ pageTitle: 'Access Tokens',
2179
+ menuTokens: true,
2180
+ data,
2181
+
2182
+ account: accountData,
2183
+
2184
+ showPaging: data.pages > 1,
2185
+ nextPage,
2186
+ prevPage,
2187
+ firstPage: data.page === 0,
2188
+ pageLinks: new Array(data.pages || 1).fill(0).map((z, i) => ({
2189
+ url: getPagingUrl(i + 1, request.query.state, request.query.query),
2190
+ title: i + 1,
2191
+ active: i === data.page
2192
+ })),
2193
+
2194
+ newLink: newLink.pathname + newLink.search
2195
+ },
2196
+ {
2197
+ layout: 'app'
2198
+ }
2199
+ );
2200
+ },
2201
+
2202
+ options: {
2203
+ validate: {
2204
+ options: {
2205
+ stripUnknown: true,
2206
+ abortEarly: false,
2207
+ convert: true
2208
+ },
2209
+
2210
+ async failAction(request, h /*, err*/) {
2211
+ return h.redirect('/admin/tokens').takeover();
2212
+ },
2213
+
2214
+ query: Joi.object({
2215
+ account: accountIdSchema.default(null),
2216
+ page: Joi.number().integer().min(1).max(1000000).default(1),
2217
+ pageSize: Joi.number().integer().min(1).max(250).default(DEFAULT_PAGE_SIZE)
2218
+ })
2219
+ }
2220
+ }
2221
+ });
2222
+
2223
+ server.route({
2224
+ method: 'GET',
2225
+ path: '/admin/tokens/new',
2226
+ async handler(request, h) {
2227
+ let accountTokensLink = new URL('/admin/tokens', 'http://localhost');
2228
+
2229
+ let accountData;
2230
+ if (request.query.account) {
2231
+ let accountObject = new Account({ redis, account: request.query.account });
2232
+ accountData = await accountObject.loadAccountData();
2233
+ accountTokensLink.searchParams.append('account', request.query.account);
2234
+ }
2235
+
2236
+ return h.view(
2237
+ 'tokens/new',
2238
+ {
2239
+ pageTitle: 'Access Tokens',
2240
+ menuTokens: true,
2241
+ values: {
2242
+ scopesAll: true,
2243
+ allAccounts: !request.query.account,
2244
+ account: request.query.account
2245
+ },
2246
+ account: accountData,
2247
+ accountTokensLink: accountTokensLink.pathname + accountTokensLink.search
2248
+ },
2249
+ {
2250
+ layout: 'app'
2251
+ }
2252
+ );
2253
+ },
2254
+
2255
+ options: {
2256
+ validate: {
2257
+ options: {
2258
+ stripUnknown: true,
2259
+ abortEarly: false,
2260
+ convert: true
2261
+ },
2262
+
2263
+ async failAction(request, h /*, err*/) {
2264
+ return h.redirect('/admin/tokens').takeover();
2265
+ },
2266
+
2267
+ query: Joi.object({
2268
+ account: accountIdSchema.default(null)
2269
+ })
2270
+ }
2271
+ }
2272
+ });
2273
+
2274
+ server.route({
2275
+ method: 'POST',
2276
+ path: '/admin/tokens/new',
2277
+
2278
+ async handler(request) {
2279
+ try {
2280
+ let data = {
2281
+ ip: request.app.ip,
2282
+ remoteAddress: request.app.ip,
2283
+ description: request.payload.description,
2284
+ scopes: request.payload.scopes
2285
+ };
2286
+
2287
+ if (request.payload.account) {
2288
+ let accountObject = new Account({ redis, account: request.payload.account });
2289
+ await accountObject.loadAccountData();
2290
+ data.account = request.payload.account;
2291
+ }
2292
+
2293
+ let token = await tokens.provision(data);
2294
+
2295
+ return {
2296
+ success: true,
2297
+ token
2298
+ };
2299
+ } catch (err) {
2300
+ request.logger.error({ msg: 'Failed to generate token', err, remoteAddress: request.app.ip, description: request.payload.description });
2301
+ if (Boom.isBoom(err)) {
2302
+ return Object.assign({ success: false }, err.output.payload);
2303
+ }
2304
+ return { success: false, error: err.code || 'Error', message: err.message };
2305
+ }
2306
+ },
2307
+ options: {
2308
+ validate: {
2309
+ options: {
2310
+ stripUnknown: true,
2311
+ abortEarly: false,
2312
+ convert: true
2313
+ },
2314
+
2315
+ failAction,
2316
+
2317
+ payload: Joi.object({
2318
+ description: Joi.string().empty('').trim().max(1024).required().example('Token description').description('Token description'),
2319
+ scopes: Joi.array()
2320
+ .items(Joi.string().valid('*', 'api', 'metrics', 'smtp', 'imap-proxy'))
2321
+ .required()
2322
+ .label('Scopes'),
2323
+ account: accountIdSchema.default(null)
2324
+ })
2325
+ }
2326
+ }
2327
+ });
2328
+
2329
+ server.route({
2330
+ method: 'POST',
2331
+ path: '/admin/tokens/delete',
2332
+ async handler(request, h) {
2333
+ try {
2334
+ let deleted = await tokens.delete(request.payload.token, { remoteAddress: request.app.ip });
2335
+ if (deleted) {
2336
+ await request.flash({ type: 'info', message: `Token deleted` });
2337
+ }
2338
+
2339
+ return h.redirect('/admin/tokens');
2340
+ } catch (err) {
2341
+ await request.flash({ type: 'danger', message: `Couldn't delete token. Try again.` });
2342
+ request.logger.error({ msg: 'Failed to delete access token', err, token: request.payload.token, remoteAddress: request.app.ip });
2343
+ return h.redirect('/admin/tokens');
2344
+ }
2345
+ },
2346
+ options: {
2347
+ validate: {
2348
+ options: {
2349
+ stripUnknown: true,
2350
+ abortEarly: false,
2351
+ convert: true
2352
+ },
2353
+
2354
+ async failAction(request, h, err) {
2355
+ await request.flash({ type: 'danger', message: `Couldn't delete token. Try again.` });
2356
+ request.logger.error({ msg: 'Failed to delete access token', err });
2357
+
2358
+ return h.redirect('/admin/tokens').takeover();
2359
+ },
2360
+
2361
+ payload: Joi.object({ token: Joi.string().length(64).hex().required().example('123456').description('Access token') })
2362
+ }
2363
+ }
2364
+ });
2365
+ }
2366
+
2367
+ module.exports = init;