emailengine-app 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (405) hide show
  1. package/.eslintrc +14 -0
  2. package/.github/CODE_OF_CONDUCT.md +76 -0
  3. package/.github/FUNDING.yml +4 -0
  4. package/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
  5. package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  6. package/.github/contributing.md +17 -0
  7. package/.ncurc.js +10 -0
  8. package/.prettierrc.js +8 -0
  9. package/Dockerfile +17 -0
  10. package/Gruntfile.js +16 -0
  11. package/LICENSE.txt +661 -0
  12. package/README.md +524 -0
  13. package/bin/emailengine.js +14 -0
  14. package/config/default.toml +39 -0
  15. package/docker-compose.yml +47 -0
  16. package/encrypt.js +179 -0
  17. package/examples/api.md +137 -0
  18. package/examples/auth-server.js +104 -0
  19. package/getswagger.sh +8 -0
  20. package/lib/account.js +562 -0
  21. package/lib/append-list.js +67 -0
  22. package/lib/bounce-detect.js +380 -0
  23. package/lib/connection.js +1753 -0
  24. package/lib/consts.js +22 -0
  25. package/lib/db.js +72 -0
  26. package/lib/encrypt.js +100 -0
  27. package/lib/enum-message-flags.js +6 -0
  28. package/lib/get-raw-email.js +292 -0
  29. package/lib/get-secret.js +83 -0
  30. package/lib/logger.js +35 -0
  31. package/lib/lua/s-list-accounts.lua +51 -0
  32. package/lib/lua/z-expunge.lua +20 -0
  33. package/lib/lua/z-get-by-uid.lua +16 -0
  34. package/lib/lua/z-get-mailbox-id.lua +15 -0
  35. package/lib/lua/z-get-mailbox-path.lua +4 -0
  36. package/lib/lua/z-get.lua +15 -0
  37. package/lib/lua/z-push.lua +14 -0
  38. package/lib/lua/z-set.lua +17 -0
  39. package/lib/mailbox.js +1545 -0
  40. package/lib/message-port-stream.js +79 -0
  41. package/lib/schemas.js +311 -0
  42. package/lib/settings.js +63 -0
  43. package/lib/tools.js +488 -0
  44. package/package.json +79 -0
  45. package/scan.js +111 -0
  46. package/server.js +672 -0
  47. package/static/bootstrap-4.6.0-dist/css/bootstrap-grid.css +3872 -0
  48. package/static/bootstrap-4.6.0-dist/css/bootstrap-grid.css.map +1 -0
  49. package/static/bootstrap-4.6.0-dist/css/bootstrap-grid.min.css +7 -0
  50. package/static/bootstrap-4.6.0-dist/css/bootstrap-grid.min.css.map +1 -0
  51. package/static/bootstrap-4.6.0-dist/css/bootstrap-reboot.css +325 -0
  52. package/static/bootstrap-4.6.0-dist/css/bootstrap-reboot.css.map +1 -0
  53. package/static/bootstrap-4.6.0-dist/css/bootstrap-reboot.min.css +8 -0
  54. package/static/bootstrap-4.6.0-dist/css/bootstrap-reboot.min.css.map +1 -0
  55. package/static/bootstrap-4.6.0-dist/css/bootstrap.css +10298 -0
  56. package/static/bootstrap-4.6.0-dist/css/bootstrap.css.map +1 -0
  57. package/static/bootstrap-4.6.0-dist/css/bootstrap.min.css +7 -0
  58. package/static/bootstrap-4.6.0-dist/css/bootstrap.min.css.map +1 -0
  59. package/static/bootstrap-4.6.0-dist/js/bootstrap.bundle.js +7045 -0
  60. package/static/bootstrap-4.6.0-dist/js/bootstrap.bundle.js.map +1 -0
  61. package/static/bootstrap-4.6.0-dist/js/bootstrap.bundle.min.js +7 -0
  62. package/static/bootstrap-4.6.0-dist/js/bootstrap.bundle.min.js.map +1 -0
  63. package/static/bootstrap-4.6.0-dist/js/bootstrap.js +4432 -0
  64. package/static/bootstrap-4.6.0-dist/js/bootstrap.js.map +1 -0
  65. package/static/bootstrap-4.6.0-dist/js/bootstrap.min.js +7 -0
  66. package/static/bootstrap-4.6.0-dist/js/bootstrap.min.js.map +1 -0
  67. package/static/css/callout.css +63 -0
  68. package/static/css/emailengine.css +33 -0
  69. package/static/favicon/android-chrome-192x192.png +0 -0
  70. package/static/favicon/android-chrome-512x512.png +0 -0
  71. package/static/favicon/apple-touch-icon.png +0 -0
  72. package/static/favicon/favicon-16x16.png +0 -0
  73. package/static/favicon/favicon-32x32.png +0 -0
  74. package/static/favicon/manifest.json +20 -0
  75. package/static/favicon.ico +0 -0
  76. package/static/icons/alarm-fill.svg +3 -0
  77. package/static/icons/alarm.svg +7 -0
  78. package/static/icons/alert-circle-fill.svg +3 -0
  79. package/static/icons/alert-circle.svg +4 -0
  80. package/static/icons/alert-octagon-fill.svg +3 -0
  81. package/static/icons/alert-octagon.svg +5 -0
  82. package/static/icons/alert-square-fill.svg +3 -0
  83. package/static/icons/alert-square.svg +5 -0
  84. package/static/icons/alert-triangle-fill.svg +3 -0
  85. package/static/icons/alert-triangle.svg +5 -0
  86. package/static/icons/archive-fill.svg +3 -0
  87. package/static/icons/archive.svg +4 -0
  88. package/static/icons/arrow-bar-bottom.svg +4 -0
  89. package/static/icons/arrow-bar-left.svg +4 -0
  90. package/static/icons/arrow-bar-right.svg +4 -0
  91. package/static/icons/arrow-bar-up.svg +4 -0
  92. package/static/icons/arrow-clockwise.svg +4 -0
  93. package/static/icons/arrow-counterclockwise.svg +4 -0
  94. package/static/icons/arrow-down-left.svg +4 -0
  95. package/static/icons/arrow-down-right.svg +4 -0
  96. package/static/icons/arrow-down-short.svg +4 -0
  97. package/static/icons/arrow-down.svg +4 -0
  98. package/static/icons/arrow-left-right.svg +5 -0
  99. package/static/icons/arrow-left-short.svg +4 -0
  100. package/static/icons/arrow-left.svg +4 -0
  101. package/static/icons/arrow-repeat.svg +5 -0
  102. package/static/icons/arrow-right-short.svg +4 -0
  103. package/static/icons/arrow-right.svg +4 -0
  104. package/static/icons/arrow-up-down.svg +5 -0
  105. package/static/icons/arrow-up-left.svg +4 -0
  106. package/static/icons/arrow-up-right.svg +4 -0
  107. package/static/icons/arrow-up-short.svg +4 -0
  108. package/static/icons/arrow-up.svg +4 -0
  109. package/static/icons/arrows-angle-contract.svg +5 -0
  110. package/static/icons/arrows-angle-expand.svg +5 -0
  111. package/static/icons/arrows-collapse.svg +5 -0
  112. package/static/icons/arrows-expand.svg +5 -0
  113. package/static/icons/arrows-fullscreen.svg +7 -0
  114. package/static/icons/at.svg +3 -0
  115. package/static/icons/award.svg +4 -0
  116. package/static/icons/backspace-fill.svg +3 -0
  117. package/static/icons/backspace-reverse-fill.svg +3 -0
  118. package/static/icons/backspace-reverse.svg +5 -0
  119. package/static/icons/backspace.svg +5 -0
  120. package/static/icons/bar-chart-fill.svg +5 -0
  121. package/static/icons/bar-chart.svg +3 -0
  122. package/static/icons/battery-charging.svg +5 -0
  123. package/static/icons/battery-full.svg +4 -0
  124. package/static/icons/battery.svg +4 -0
  125. package/static/icons/bell-fill.svg +3 -0
  126. package/static/icons/bell.svg +4 -0
  127. package/static/icons/blockquote-left.svg +4 -0
  128. package/static/icons/blockquote-right.svg +4 -0
  129. package/static/icons/book-half-fill.svg +4 -0
  130. package/static/icons/book.svg +4 -0
  131. package/static/icons/bookmark-fill.svg +3 -0
  132. package/static/icons/bookmark.svg +3 -0
  133. package/static/icons/bootstrap-fill.svg +3 -0
  134. package/static/icons/bootstrap-reboot.svg +3 -0
  135. package/static/icons/bootstrap.svg +4 -0
  136. package/static/icons/box-arrow-bottom-left.svg +4 -0
  137. package/static/icons/box-arrow-bottom-right.svg +4 -0
  138. package/static/icons/box-arrow-down.svg +5 -0
  139. package/static/icons/box-arrow-left.svg +5 -0
  140. package/static/icons/box-arrow-right.svg +5 -0
  141. package/static/icons/box-arrow-up-left.svg +4 -0
  142. package/static/icons/box-arrow-up-right.svg +4 -0
  143. package/static/icons/box-arrow-up.svg +5 -0
  144. package/static/icons/braces.svg +3 -0
  145. package/static/icons/brightness-fill-high.svg +4 -0
  146. package/static/icons/brightness-fill-low.svg +11 -0
  147. package/static/icons/brightness-high.svg +3 -0
  148. package/static/icons/brightness-low.svg +11 -0
  149. package/static/icons/brush.svg +4 -0
  150. package/static/icons/bucket-fill.svg +4 -0
  151. package/static/icons/bucket.svg +4 -0
  152. package/static/icons/building.svg +5 -0
  153. package/static/icons/bullseye.svg +6 -0
  154. package/static/icons/calendar-fill.svg +4 -0
  155. package/static/icons/calendar.svg +4 -0
  156. package/static/icons/camera-video-fill.svg +4 -0
  157. package/static/icons/camera-video.svg +4 -0
  158. package/static/icons/camera.svg +5 -0
  159. package/static/icons/capslock-fill.svg +3 -0
  160. package/static/icons/capslock.svg +3 -0
  161. package/static/icons/chat-fill.svg +3 -0
  162. package/static/icons/chat.svg +3 -0
  163. package/static/icons/check-box.svg +4 -0
  164. package/static/icons/check-circle.svg +4 -0
  165. package/static/icons/check.svg +3 -0
  166. package/static/icons/chevron-compact-down.svg +3 -0
  167. package/static/icons/chevron-compact-left.svg +3 -0
  168. package/static/icons/chevron-compact-right.svg +3 -0
  169. package/static/icons/chevron-compact-up.svg +3 -0
  170. package/static/icons/chevron-down.svg +3 -0
  171. package/static/icons/chevron-left.svg +3 -0
  172. package/static/icons/chevron-right.svg +3 -0
  173. package/static/icons/chevron-up.svg +3 -0
  174. package/static/icons/circle-fill.svg +3 -0
  175. package/static/icons/circle-half.svg +3 -0
  176. package/static/icons/circle-slash.svg +3 -0
  177. package/static/icons/circle.svg +3 -0
  178. package/static/icons/clock-fill.svg +3 -0
  179. package/static/icons/clock.svg +4 -0
  180. package/static/icons/cloud-download.svg +5 -0
  181. package/static/icons/cloud-fill.svg +3 -0
  182. package/static/icons/cloud-upload.svg +5 -0
  183. package/static/icons/cloud.svg +3 -0
  184. package/static/icons/code-slash.svg +3 -0
  185. package/static/icons/code.svg +3 -0
  186. package/static/icons/columns-gutters.svg +3 -0
  187. package/static/icons/columns.svg +4 -0
  188. package/static/icons/command.svg +4 -0
  189. package/static/icons/compass.svg +5 -0
  190. package/static/icons/cone-striped.svg +4 -0
  191. package/static/icons/cone.svg +4 -0
  192. package/static/icons/controller.svg +5 -0
  193. package/static/icons/credit-card.svg +5 -0
  194. package/static/icons/cursor-fill.svg +3 -0
  195. package/static/icons/cursor.svg +3 -0
  196. package/static/icons/dash.svg +3 -0
  197. package/static/icons/diamond-half.svg +3 -0
  198. package/static/icons/diamond.svg +3 -0
  199. package/static/icons/display-fill.svg +5 -0
  200. package/static/icons/display.svg +4 -0
  201. package/static/icons/document-code.svg +4 -0
  202. package/static/icons/document-diff.svg +5 -0
  203. package/static/icons/document-richtext.svg +4 -0
  204. package/static/icons/document-spreadsheet.svg +5 -0
  205. package/static/icons/document-text.svg +4 -0
  206. package/static/icons/document.svg +3 -0
  207. package/static/icons/documents-alt.svg +4 -0
  208. package/static/icons/documents.svg +4 -0
  209. package/static/icons/dot.svg +3 -0
  210. package/static/icons/download.svg +5 -0
  211. package/static/icons/egg-fried.svg +4 -0
  212. package/static/icons/eject-fill.svg +3 -0
  213. package/static/icons/eject.svg +3 -0
  214. package/static/icons/envelope-fill.svg +3 -0
  215. package/static/icons/envelope-open-fill.svg +3 -0
  216. package/static/icons/envelope-open.svg +5 -0
  217. package/static/icons/envelope.svg +4 -0
  218. package/static/icons/eye-fill.svg +4 -0
  219. package/static/icons/eye-slash-fill.svg +5 -0
  220. package/static/icons/eye-slash.svg +6 -0
  221. package/static/icons/eye.svg +4 -0
  222. package/static/icons/filter.svg +3 -0
  223. package/static/icons/flag-fill.svg +4 -0
  224. package/static/icons/flag.svg +4 -0
  225. package/static/icons/folder-fill.svg +3 -0
  226. package/static/icons/folder-symlink-fill.svg +3 -0
  227. package/static/icons/folder-symlink.svg +5 -0
  228. package/static/icons/folder.svg +4 -0
  229. package/static/icons/fonts.svg +3 -0
  230. package/static/icons/forward-fill.svg +3 -0
  231. package/static/icons/forward.svg +3 -0
  232. package/static/icons/gear-fill.svg +3 -0
  233. package/static/icons/gear-wide-connected.svg +4 -0
  234. package/static/icons/gear-wide.svg +3 -0
  235. package/static/icons/gear.svg +4 -0
  236. package/static/icons/geo.svg +5 -0
  237. package/static/icons/graph-down.svg +5 -0
  238. package/static/icons/graph-up.svg +5 -0
  239. package/static/icons/grid-fill.svg +6 -0
  240. package/static/icons/grid.svg +3 -0
  241. package/static/icons/hammer.svg +4 -0
  242. package/static/icons/hash.svg +3 -0
  243. package/static/icons/heart-fill.svg +3 -0
  244. package/static/icons/heart.svg +3 -0
  245. package/static/icons/house-fill.svg +4 -0
  246. package/static/icons/house.svg +4 -0
  247. package/static/icons/image-alt.svg +4 -0
  248. package/static/icons/image-fill.svg +3 -0
  249. package/static/icons/image.svg +5 -0
  250. package/static/icons/images.svg +5 -0
  251. package/static/icons/inbox-fill.svg +4 -0
  252. package/static/icons/inbox.svg +4 -0
  253. package/static/icons/inboxes-fill.svg +4 -0
  254. package/static/icons/inboxes.svg +4 -0
  255. package/static/icons/info-fill.svg +3 -0
  256. package/static/icons/info-square-fill.svg +3 -0
  257. package/static/icons/info-square.svg +5 -0
  258. package/static/icons/info.svg +5 -0
  259. package/static/icons/justify-left.svg +3 -0
  260. package/static/icons/justify-right.svg +3 -0
  261. package/static/icons/justify.svg +3 -0
  262. package/static/icons/kanban-fill.svg +3 -0
  263. package/static/icons/kanban.svg +6 -0
  264. package/static/icons/laptop.svg +4 -0
  265. package/static/icons/layout-sidebar-reverse.svg +4 -0
  266. package/static/icons/layout-sidebar.svg +4 -0
  267. package/static/icons/layout-split.svg +3 -0
  268. package/static/icons/list-check.svg +3 -0
  269. package/static/icons/list-ol.svg +4 -0
  270. package/static/icons/list-task.svg +5 -0
  271. package/static/icons/list-ul.svg +3 -0
  272. package/static/icons/list.svg +3 -0
  273. package/static/icons/lock-fill.svg +4 -0
  274. package/static/icons/lock.svg +3 -0
  275. package/static/icons/map.svg +3 -0
  276. package/static/icons/mic.svg +4 -0
  277. package/static/icons/moon.svg +3 -0
  278. package/static/icons/music-player-fill.svg +4 -0
  279. package/static/icons/music-player.svg +5 -0
  280. package/static/icons/option.svg +3 -0
  281. package/static/icons/outlet.svg +5 -0
  282. package/static/icons/pause-fill.svg +3 -0
  283. package/static/icons/pause.svg +3 -0
  284. package/static/icons/pen.svg +5 -0
  285. package/static/icons/pencil.svg +4 -0
  286. package/static/icons/people-fill.svg +3 -0
  287. package/static/icons/people.svg +3 -0
  288. package/static/icons/person-fill.svg +3 -0
  289. package/static/icons/person.svg +3 -0
  290. package/static/icons/phone-landscape.svg +4 -0
  291. package/static/icons/phone.svg +4 -0
  292. package/static/icons/pie-chart-fill.svg +3 -0
  293. package/static/icons/pie-chart.svg +4 -0
  294. package/static/icons/play-fill.svg +3 -0
  295. package/static/icons/play.svg +3 -0
  296. package/static/icons/plug.svg +4 -0
  297. package/static/icons/plus.svg +4 -0
  298. package/static/icons/power.svg +4 -0
  299. package/static/icons/question-fill.svg +3 -0
  300. package/static/icons/question-square-fill.svg +3 -0
  301. package/static/icons/question-square.svg +4 -0
  302. package/static/icons/question.svg +4 -0
  303. package/static/icons/reply-all-fill.svg +4 -0
  304. package/static/icons/reply-all.svg +4 -0
  305. package/static/icons/reply-fill.svg +3 -0
  306. package/static/icons/reply.svg +3 -0
  307. package/static/icons/screwdriver.svg +3 -0
  308. package/static/icons/search.svg +4 -0
  309. package/static/icons/shield-fill.svg +3 -0
  310. package/static/icons/shield-lock-fill.svg +3 -0
  311. package/static/icons/shield-lock.svg +5 -0
  312. package/static/icons/shield-shaded.svg +4 -0
  313. package/static/icons/shield.svg +3 -0
  314. package/static/icons/shift-fill.svg +3 -0
  315. package/static/icons/shift.svg +3 -0
  316. package/static/icons/skip-backward-fill.svg +4 -0
  317. package/static/icons/skip-backward.svg +3 -0
  318. package/static/icons/skip-end-fill.svg +5 -0
  319. package/static/icons/skip-end.svg +4 -0
  320. package/static/icons/skip-forward-fill.svg +5 -0
  321. package/static/icons/skip-forward.svg +3 -0
  322. package/static/icons/skip-start-fill.svg +4 -0
  323. package/static/icons/skip-start.svg +4 -0
  324. package/static/icons/speaker.svg +4 -0
  325. package/static/icons/square-fill.svg +3 -0
  326. package/static/icons/square-half.svg +3 -0
  327. package/static/icons/square.svg +3 -0
  328. package/static/icons/star-fill.svg +3 -0
  329. package/static/icons/star-half.svg +3 -0
  330. package/static/icons/star.svg +3 -0
  331. package/static/icons/stop-fill.svg +3 -0
  332. package/static/icons/stop.svg +3 -0
  333. package/static/icons/stopwatch-fill.svg +3 -0
  334. package/static/icons/stopwatch.svg +5 -0
  335. package/static/icons/sun.svg +4 -0
  336. package/static/icons/table.svg +7 -0
  337. package/static/icons/tablet-landscape.svg +4 -0
  338. package/static/icons/tablet.svg +4 -0
  339. package/static/icons/tag-fill.svg +3 -0
  340. package/static/icons/tag.svg +4 -0
  341. package/static/icons/terminal-fill.svg +3 -0
  342. package/static/icons/terminal.svg +4 -0
  343. package/static/icons/text-center.svg +3 -0
  344. package/static/icons/text-indent-left.svg +3 -0
  345. package/static/icons/text-indent-right.svg +3 -0
  346. package/static/icons/text-left.svg +3 -0
  347. package/static/icons/text-right.svg +3 -0
  348. package/static/icons/three-dots-vertical.svg +3 -0
  349. package/static/icons/three-dots.svg +3 -0
  350. package/static/icons/toggle-off.svg +3 -0
  351. package/static/icons/toggle-on.svg +3 -0
  352. package/static/icons/toggles.svg +4 -0
  353. package/static/icons/tools.svg +4 -0
  354. package/static/icons/trash-fill.svg +3 -0
  355. package/static/icons/trash.svg +4 -0
  356. package/static/icons/triangle-fill.svg +3 -0
  357. package/static/icons/triangle-half.svg +3 -0
  358. package/static/icons/triangle.svg +3 -0
  359. package/static/icons/trophy.svg +6 -0
  360. package/static/icons/tv-fill.svg +3 -0
  361. package/static/icons/tv.svg +3 -0
  362. package/static/icons/type-bold.svg +3 -0
  363. package/static/icons/type-h1.svg +3 -0
  364. package/static/icons/type-h2.svg +3 -0
  365. package/static/icons/type-h3.svg +3 -0
  366. package/static/icons/type-italic.svg +3 -0
  367. package/static/icons/type-strikethrough.svg +4 -0
  368. package/static/icons/type-underline.svg +4 -0
  369. package/static/icons/type.svg +3 -0
  370. package/static/icons/unlock-fill.svg +4 -0
  371. package/static/icons/unlock.svg +3 -0
  372. package/static/icons/upload.svg +4 -0
  373. package/static/icons/volume-down-fill.svg +4 -0
  374. package/static/icons/volume-down.svg +4 -0
  375. package/static/icons/volume-mute-fill.svg +4 -0
  376. package/static/icons/volume-mute.svg +4 -0
  377. package/static/icons/volume-up-fill.svg +6 -0
  378. package/static/icons/volume-up.svg +6 -0
  379. package/static/icons/wallet.svg +3 -0
  380. package/static/icons/watch.svg +5 -0
  381. package/static/icons/wifi.svg +5 -0
  382. package/static/icons/window.svg +5 -0
  383. package/static/icons/wrench.svg +3 -0
  384. package/static/icons/x-circle-fill.svg +3 -0
  385. package/static/icons/x-circle.svg +5 -0
  386. package/static/icons/x-octagon-fill.svg +3 -0
  387. package/static/icons/x-octagon.svg +4 -0
  388. package/static/icons/x-square-fill.svg +3 -0
  389. package/static/icons/x-square.svg +4 -0
  390. package/static/icons/x.svg +4 -0
  391. package/static/index.html +752 -0
  392. package/static/js/emailengine.js +581 -0
  393. package/static/js/jquery-3.4.1.slim.min.js +2 -0
  394. package/static/js/moment-with-locales-2.24.0.min.js +1 -0
  395. package/static/js/popper.min.js +5 -0
  396. package/static/logo.png +0 -0
  397. package/systemd/emailengine.service +89 -0
  398. package/systemd/nginx-proxy.conf +77 -0
  399. package/views/error.hbs +2 -0
  400. package/workers/api.js +2266 -0
  401. package/workers/arena.js +89 -0
  402. package/workers/imap.js +611 -0
  403. package/workers/smtp.js +278 -0
  404. package/workers/submit.js +214 -0
  405. package/workers/webhooks.js +134 -0
package/workers/api.js ADDED
@@ -0,0 +1,2266 @@
1
+ 'use strict';
2
+
3
+ const { parentPort } = require('worker_threads');
4
+ const Hapi = require('@hapi/hapi');
5
+ const Boom = require('@hapi/boom');
6
+ const BasicAuth = require('@hapi/basic');
7
+ const Joi = require('joi');
8
+ const logger = require('../lib/logger');
9
+ const hapiPino = require('hapi-pino');
10
+ const { ImapFlow } = require('imapflow');
11
+ const nodemailer = require('nodemailer');
12
+ const Inert = require('@hapi/inert');
13
+ const Vision = require('@hapi/vision');
14
+ const HapiSwagger = require('hapi-swagger');
15
+ const packageData = require('../package.json');
16
+ const pathlib = require('path');
17
+ const config = require('wild-config');
18
+ const crypto = require('crypto');
19
+ const { PassThrough } = require('stream');
20
+ const msgpack = require('msgpack5')();
21
+ const { OAuth2Client } = require('google-auth-library');
22
+ const consts = require('../lib/consts');
23
+
24
+ const { redis } = require('../lib/db');
25
+ const { Account } = require('../lib/account');
26
+ const settings = require('../lib/settings');
27
+ const { getByteSize, getDuration, getCounterValues, getAuthSettings } = require('../lib/tools');
28
+
29
+ const getSecret = require('../lib/get-secret');
30
+
31
+ const {
32
+ settingsSchema,
33
+ addressSchema,
34
+ settingsQuerySchema,
35
+ imapSchema,
36
+ smtpSchema,
37
+ oauth2Schema,
38
+ messageDetailsSchema,
39
+ messageListSchema,
40
+ mailboxesSchema,
41
+ shortMailboxesSchema
42
+ } = require('../lib/schemas');
43
+
44
+ const DEFAULT_EENGINE_TIMEOUT = 10 * 1000;
45
+ const DEFAULT_MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024;
46
+
47
+ config.api = config.api || {
48
+ port: 3000,
49
+ host: '127.0.0.1'
50
+ };
51
+
52
+ config.service = config.service || {};
53
+
54
+ const EENGINE_TIMEOUT = getDuration(process.env.EENGINE_TIMEOUT || config.service.commandTimeout) || DEFAULT_EENGINE_TIMEOUT;
55
+ const MAX_ATTACHMENT_SIZE = getByteSize(process.env.EENGINE_MAX_SIZE || config.api.maxSize) || DEFAULT_MAX_ATTACHMENT_SIZE;
56
+ const EENGINE_AUTH = getAuthSettings(process.env.EENGINE_AUTH || config.api.auth);
57
+
58
+ const API_PORT = (process.env.EENGINE_PORT && Number(process.env.EENGINE_PORT)) || config.api.port;
59
+ const API_HOST = process.env.EENGINE_HOST || config.api.host;
60
+
61
+ const failAction = async (request, h, err) => {
62
+ let details = (err.details || []).map(detail => ({ message: detail.message, key: detail.context.key }));
63
+
64
+ logger.error({
65
+ msg: 'Request failed',
66
+ method: request.method,
67
+ route: request.route.path,
68
+ statusCode: request.response && request.response.statusCode,
69
+ err
70
+ });
71
+
72
+ let message = 'Invalid input';
73
+ let error = Boom.boomify(new Error(message), { statusCode: 400 });
74
+ error.output.payload.fields = details;
75
+ throw error;
76
+ };
77
+
78
+ let callQueue = new Map();
79
+ let mids = 0;
80
+
81
+ async function call(message, transferList) {
82
+ return new Promise((resolve, reject) => {
83
+ let mid = `${Date.now()}:${++mids}`;
84
+
85
+ let timer = setTimeout(() => {
86
+ let err = new Error('Timeout waiting for command response');
87
+ err.statusCode = 504;
88
+ err.code = 'Timeout';
89
+ reject(err);
90
+ }, message.timeout || EENGINE_TIMEOUT);
91
+
92
+ callQueue.set(mid, { resolve, reject, timer });
93
+
94
+ parentPort.postMessage(
95
+ {
96
+ cmd: 'call',
97
+ mid,
98
+ message
99
+ },
100
+ transferList
101
+ );
102
+ });
103
+ }
104
+
105
+ async function metrics(logger, key, method, ...args) {
106
+ try {
107
+ parentPort.postMessage({
108
+ cmd: 'metrics',
109
+ key,
110
+ method,
111
+ args
112
+ });
113
+ } catch (err) {
114
+ logger.error({ msg: 'Failed to post metrics to parent', err });
115
+ }
116
+ }
117
+
118
+ async function notify(cmd, data) {
119
+ parentPort.postMessage({
120
+ cmd,
121
+ data
122
+ });
123
+ }
124
+
125
+ async function onCommand(command) {
126
+ logger.debug({ msg: 'Unhandled command', command });
127
+ }
128
+
129
+ parentPort.on('message', message => {
130
+ if (message && message.cmd === 'resp' && message.mid && callQueue.has(message.mid)) {
131
+ let { resolve, reject, timer } = callQueue.get(message.mid);
132
+ clearTimeout(timer);
133
+ callQueue.delete(message.mid);
134
+ if (message.error) {
135
+ let err = new Error(message.error);
136
+ if (message.code) {
137
+ err.code = message.code;
138
+ }
139
+ if (message.statusCode) {
140
+ err.statusCode = message.statusCode;
141
+ }
142
+ return reject(err);
143
+ } else {
144
+ return resolve(message.response);
145
+ }
146
+ }
147
+
148
+ if (message && message.cmd === 'call' && message.mid) {
149
+ return onCommand(message.message)
150
+ .then(response => {
151
+ parentPort.postMessage({
152
+ cmd: 'resp',
153
+ mid: message.mid,
154
+ response
155
+ });
156
+ })
157
+ .catch(err => {
158
+ parentPort.postMessage({
159
+ cmd: 'resp',
160
+ mid: message.mid,
161
+ error: err.message,
162
+ code: err.code,
163
+ statusCode: err.statusCode
164
+ });
165
+ });
166
+ }
167
+ });
168
+
169
+ const getOAuth2Client = async () => {
170
+ let keys = {
171
+ clientId: await settings.get('gmailClientId'),
172
+ clientSecret: await settings.get('gmailClientSecret'),
173
+ redirectUrl: await settings.get('gmailRedirectUrl')
174
+ };
175
+
176
+ if (!keys.clientId || !keys.clientSecret || !keys.redirectUrl) {
177
+ let error = Boom.boomify(new Error('OAuth2 credentials not set up'), { statusCode: 400 });
178
+ throw error;
179
+ }
180
+
181
+ return new OAuth2Client(keys.clientId, keys.clientSecret, keys.redirectUrl);
182
+ };
183
+
184
+ const init = async () => {
185
+ const server = Hapi.server({
186
+ port: (process.env.EENGINE_PORT && Number(process.env.EENGINE_PORT)) || config.api.port,
187
+ host: process.env.EENGINE_HOST || config.api.host
188
+ });
189
+
190
+ const swaggerOptions = {
191
+ swaggerUI: true,
192
+ swaggerUIPath: '/swagger/',
193
+ documentationPage: true,
194
+ documentationPath: '/docs',
195
+
196
+ grouping: 'tags',
197
+
198
+ info: {
199
+ title: 'EmailEngine',
200
+ version: packageData.version,
201
+ contact: {
202
+ name: 'Andris Reinman',
203
+ email: 'andris@emailengine.app'
204
+ }
205
+ }
206
+ };
207
+
208
+ const validateBasicAuth = async (request, username, password /*, h*/) => {
209
+ if (!EENGINE_AUTH.enabled) {
210
+ return { credentials: null, isValid: true };
211
+ }
212
+
213
+ if (username.trim() !== EENGINE_AUTH.user || password !== EENGINE_AUTH.pass) {
214
+ return { credentials: null, isValid: false };
215
+ }
216
+
217
+ return { isValid: true, credentials: { id: username } };
218
+ };
219
+
220
+ if (EENGINE_AUTH.enabled) {
221
+ // setup basic auth
222
+ await server.register(BasicAuth);
223
+
224
+ server.auth.strategy('simple', 'basic', { validate: validateBasicAuth });
225
+ server.auth.default('simple');
226
+ }
227
+
228
+ await server.register({
229
+ plugin: hapiPino,
230
+ options: {
231
+ instance: logger.child({ component: 'api' }),
232
+ // Redact Authorization headers, see https://getpino.io/#/docs/redaction
233
+ redact: ['req.headers.authorization']
234
+ }
235
+ });
236
+
237
+ await server.register([
238
+ Inert,
239
+ Vision,
240
+ {
241
+ plugin: HapiSwagger,
242
+ options: swaggerOptions
243
+ }
244
+ ]);
245
+
246
+ server.events.on('response', request => {
247
+ if (!/^\/v1\//.test(request.route.path)) {
248
+ // only log API calls
249
+ return;
250
+ }
251
+ metrics(logger, 'apiCall', 'inc', {
252
+ method: request.method,
253
+ route: request.route.path,
254
+ statusCode: request.response && request.response.statusCode
255
+ });
256
+ });
257
+
258
+ server.route({
259
+ method: 'GET',
260
+ path: '/',
261
+ handler: {
262
+ file: pathlib.join(__dirname, '..', 'static', 'index.html')
263
+ }
264
+ });
265
+
266
+ server.route({
267
+ method: 'GET',
268
+ path: '/favicon.ico',
269
+ handler: {
270
+ file: pathlib.join(__dirname, '..', 'static', 'favicon.ico')
271
+ }
272
+ });
273
+
274
+ server.route({
275
+ method: 'GET',
276
+ path: '/static/{file*}',
277
+ handler: {
278
+ directory: {
279
+ path: pathlib.join(__dirname, '..', 'static')
280
+ }
281
+ }
282
+ });
283
+
284
+ server.route({
285
+ method: 'GET',
286
+ path: '/oauth',
287
+ async handler(request, h) {
288
+ const oAuth2Client = await getOAuth2Client();
289
+
290
+ if (request.query.error) {
291
+ let error = Boom.boomify(new Error(`Oauth failed: ${request.query.error}`), { statusCode: 400 });
292
+ throw error;
293
+ }
294
+
295
+ if (!request.query.code) {
296
+ // throw
297
+ let error = Boom.boomify(new Error(`Oauth failed: node code received`), { statusCode: 400 });
298
+ throw error;
299
+ }
300
+
301
+ if (!/^account:add:/.test(request.query.state)) {
302
+ let error = Boom.boomify(new Error(`Oauth failed: invalid state received`), { statusCode: 400 });
303
+ throw error;
304
+ }
305
+
306
+ let [[, accountData]] = await redis.multi().get(request.query.state).del(request.query.state).exec();
307
+ if (!accountData) {
308
+ let error = Boom.boomify(new Error(`Oauth failed: session expired`), { statusCode: 400 });
309
+ throw error;
310
+ }
311
+
312
+ try {
313
+ accountData = JSON.parse(accountData);
314
+ } catch (E) {
315
+ let error = Boom.boomify(new Error(`Oauth failed: invalid session`), { statusCode: 400 });
316
+ throw error;
317
+ }
318
+
319
+ const r = await oAuth2Client.getToken(request.query.code);
320
+ if (!r || !r.tokens) {
321
+ let error = Boom.boomify(new Error(`Oauth failed: did not get token`), { statusCode: 400 });
322
+ throw error;
323
+ }
324
+
325
+ // retrieve account email address as this is the username for IMAP/SMTP
326
+ oAuth2Client.setCredentials(r.tokens);
327
+ let profileRes;
328
+ try {
329
+ profileRes = await oAuth2Client.request({ url: 'https://gmail.googleapis.com/gmail/v1/users/me/profile' });
330
+ } catch (err) {
331
+ if (err.response && err.response.data && err.response.data.error) {
332
+ let error = Boom.boomify(new Error(err.response.data.error.message), { statusCode: err.response.data.error.code });
333
+ throw error;
334
+ }
335
+ throw err;
336
+ }
337
+
338
+ if (!profileRes || !profileRes.data || !profileRes.data.emailAddress) {
339
+ let error = Boom.boomify(new Error(`Oauth failed: failed to retrieve account email address`), { statusCode: 400 });
340
+ throw error;
341
+ }
342
+
343
+ accountData.oauth2 = Object.assign(
344
+ accountData.oauth2 || {},
345
+ {
346
+ provider: 'gmail',
347
+ accessToken: r.tokens.access_token,
348
+ refreshToken: r.tokens.refresh_token,
349
+ expires: new Date(r.tokens.expiry_date),
350
+ scope: r.tokens.scope,
351
+ tokenType: r.tokens.token_type
352
+ },
353
+ {
354
+ auth: {
355
+ user: profileRes.data.emailAddress
356
+ }
357
+ }
358
+ );
359
+
360
+ let accountObject = new Account({ redis, call, secret: await getSecret() });
361
+ let result = await accountObject.create(accountData);
362
+
363
+ return h.redirect(`/#account:created=${result.account}`);
364
+ },
365
+ options: {
366
+ description: 'OAuth2 response endpoint',
367
+
368
+ validate: {
369
+ options: {
370
+ stripUnknown: false,
371
+ abortEarly: false,
372
+ convert: true
373
+ },
374
+ failAction,
375
+
376
+ query: Joi.object({
377
+ state: Joi.string().max(1024).example('account:add:12345').description('OAuth2 state info'),
378
+ code: Joi.string().max(1024).example('67890...').description('OAuth2 setup code'),
379
+ scope: Joi.string().max(1024).example('https://mail.google.com/').description('OAuth2 scopes'),
380
+ error: Joi.string().max(1024).example('access_denied').description('OAuth2 scopes')
381
+ }).label('CreateAccount')
382
+ }
383
+ }
384
+ });
385
+
386
+ server.route({
387
+ method: 'POST',
388
+ path: '/v1/account',
389
+
390
+ async handler(request) {
391
+ let accountObject = new Account({ redis, call, secret: await getSecret() });
392
+
393
+ try {
394
+ if (request.payload.oauth2 && request.payload.oauth2.authorize) {
395
+ // redirect to OAuth2 consent screen
396
+
397
+ const oAuth2Client = await getOAuth2Client();
398
+
399
+ let nonce = crypto.randomBytes(12).toString('hex');
400
+
401
+ delete request.payload.oauth2.authorize; // do not store this property
402
+ // store account data
403
+ await redis
404
+ .multi()
405
+ .set(`account:add:${nonce}`, JSON.stringify(request.payload))
406
+ .expire(`account:add:${nonce}`, 1 * 24 * 3600)
407
+ .exec();
408
+
409
+ // Generate the url that will be used for the consent dialog.
410
+ const authorizeUrl = oAuth2Client.generateAuthUrl({
411
+ access_type: 'offline',
412
+ scope: ['https://mail.google.com/'],
413
+ state: `account:add:${nonce}`,
414
+ prompt: 'consent'
415
+ });
416
+
417
+ return {
418
+ redirect: authorizeUrl
419
+ };
420
+ }
421
+
422
+ let result = await accountObject.create(request.payload);
423
+ return result;
424
+ } catch (err) {
425
+ if (Boom.isBoom(err)) {
426
+ throw err;
427
+ }
428
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
429
+ if (err.code) {
430
+ error.output.payload.code = err.code;
431
+ }
432
+ throw error;
433
+ }
434
+ },
435
+ options: {
436
+ description: 'Register new account',
437
+ notes: 'Registers new IMAP account to be synced',
438
+ tags: ['api', 'account'],
439
+
440
+ validate: {
441
+ options: {
442
+ stripUnknown: false,
443
+ abortEarly: false,
444
+ convert: true
445
+ },
446
+ failAction,
447
+
448
+ payload: Joi.object({
449
+ account: Joi.string().max(256).required().example('example').description('Account ID'),
450
+
451
+ name: Joi.string().max(256).required().example('My Email Account').description('Display name for the account'),
452
+
453
+ path: Joi.string().empty('').max(1024).default('*').example('INBOX').description('Check changes only on selected path'),
454
+
455
+ copy: Joi.boolean().example(true).description('Copy submitted messages to Sent folder').default(true),
456
+ notifyFrom: Joi.date().example('2021-07-08T07:06:34.336Z').description('Notify messages from date').default('now').iso(),
457
+
458
+ imap: Joi.object(imapSchema).allow(false).xor('useAuthServer', 'auth').description('IMAP configuration').label('IMAP'),
459
+
460
+ smtp: Joi.object(smtpSchema).allow(false).xor('useAuthServer', 'auth').description('SMTP configuration').label('SMTP'),
461
+
462
+ oauth2: oauth2Schema.allow(false).description('OAuth2 configuration').label('OAuth2')
463
+ }).label('CreateAccount')
464
+ },
465
+
466
+ response: {
467
+ schema: Joi.object({
468
+ account: Joi.string().max(256).required().example('example').description('Account ID'),
469
+ state: Joi.string().required().valid('existing', 'new').example('new').description('Is the account new or updated existing')
470
+ }).label('CreateAccountReponse'),
471
+ failAction: 'log'
472
+ }
473
+ }
474
+ });
475
+
476
+ server.route({
477
+ method: 'PUT',
478
+ path: '/v1/account/{account}',
479
+
480
+ async handler(request) {
481
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
482
+
483
+ try {
484
+ return await accountObject.update(request.payload);
485
+ } catch (err) {
486
+ if (Boom.isBoom(err)) {
487
+ throw err;
488
+ }
489
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
490
+ if (err.code) {
491
+ error.output.payload.code = err.code;
492
+ }
493
+ throw error;
494
+ }
495
+ },
496
+ options: {
497
+ description: 'Update account info',
498
+ notes: 'Updates account information',
499
+ tags: ['api', 'account'],
500
+
501
+ validate: {
502
+ options: {
503
+ stripUnknown: false,
504
+ abortEarly: false,
505
+ convert: true
506
+ },
507
+ failAction,
508
+
509
+ params: Joi.object({
510
+ account: Joi.string().max(256).required().example('example').description('Account ID')
511
+ }),
512
+
513
+ payload: Joi.object({
514
+ name: Joi.string().max(256).example('My Email Account').description('Display name for the account'),
515
+
516
+ path: Joi.string().empty('').max(1024).default('*').example('INBOX').description('Check changes only on selected path'),
517
+
518
+ copy: Joi.boolean().example(true).description('Copy submitted messages to Sent folder').default(true),
519
+ notifyFrom: Joi.date().example('2021-07-08T07:06:34.336Z').description('Notify messages from date').default('now').iso(),
520
+
521
+ imap: Joi.object(imapSchema).xor('useAuthServer', 'auth').description('IMAP configuration').label('IMAP'),
522
+ smtp: Joi.object(smtpSchema).allow(false).xor('useAuthServer', 'auth').description('SMTP configuration').label('SMTP')
523
+ }).label('UpdateAccount')
524
+ },
525
+
526
+ response: {
527
+ schema: Joi.object({
528
+ account: Joi.string().max(256).required().example('example').description('Account ID')
529
+ }).label('UpdateAccountReponse'),
530
+ failAction: 'log'
531
+ }
532
+ }
533
+ });
534
+
535
+ server.route({
536
+ method: 'PUT',
537
+ path: '/v1/account/{account}/reconnect',
538
+
539
+ async handler(request) {
540
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
541
+
542
+ try {
543
+ return { reconnect: await accountObject.requestReconnect(request.payload) };
544
+ } catch (err) {
545
+ if (Boom.isBoom(err)) {
546
+ throw err;
547
+ }
548
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
549
+ if (err.code) {
550
+ error.output.payload.code = err.code;
551
+ }
552
+ throw error;
553
+ }
554
+ },
555
+ options: {
556
+ description: 'Request reconnect',
557
+ notes: 'Requests connection to be reconnected',
558
+ tags: ['api', 'account'],
559
+
560
+ validate: {
561
+ options: {
562
+ stripUnknown: false,
563
+ abortEarly: false,
564
+ convert: true
565
+ },
566
+ failAction,
567
+
568
+ params: Joi.object({
569
+ account: Joi.string().max(256).required().example('example').description('Account ID')
570
+ }),
571
+
572
+ payload: Joi.object({
573
+ reconnect: Joi.boolean().truthy('Y', 'true', '1').falsy('N', 'false', 0).default(false).description('Only reconnect if true')
574
+ }).label('RequestReconnect')
575
+ },
576
+
577
+ response: {
578
+ schema: Joi.object({
579
+ reconnect: Joi.boolean().truthy('Y', 'true', '1').falsy('N', 'false', 0).default(false).description('Only reconnect if true')
580
+ }).label('RequestReconnectReponse'),
581
+ failAction: 'log'
582
+ }
583
+ }
584
+ });
585
+
586
+ server.route({
587
+ method: 'DELETE',
588
+ path: '/v1/account/{account}',
589
+
590
+ async handler(request) {
591
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
592
+
593
+ try {
594
+ return await accountObject.delete();
595
+ } catch (err) {
596
+ if (Boom.isBoom(err)) {
597
+ throw err;
598
+ }
599
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
600
+ if (err.code) {
601
+ error.output.payload.code = err.code;
602
+ }
603
+ throw error;
604
+ }
605
+ },
606
+ options: {
607
+ description: 'Remove synced account',
608
+ notes: 'Stop syncing IMAP account and delete cached values',
609
+ tags: ['api', 'account'],
610
+
611
+ validate: {
612
+ options: {
613
+ stripUnknown: false,
614
+ abortEarly: false,
615
+ convert: true
616
+ },
617
+ failAction,
618
+
619
+ params: Joi.object({
620
+ account: Joi.string().max(256).required().example('example').description('Account ID')
621
+ }).label('DeleteRequest')
622
+ },
623
+
624
+ response: {
625
+ schema: Joi.object({
626
+ account: Joi.string().max(256).required().example('example').description('Account ID'),
627
+ deleted: Joi.boolean().truthy('Y', 'true', '1').falsy('N', 'false', 0).default(true).description('Was the account deleted')
628
+ }).label('DeleteRequestReponse'),
629
+ failAction: 'log'
630
+ }
631
+ }
632
+ });
633
+
634
+ server.route({
635
+ method: 'GET',
636
+ path: '/v1/accounts',
637
+
638
+ async handler(request) {
639
+ try {
640
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
641
+
642
+ return await accountObject.listAccounts(request.query.state, request.query.page, request.query.pageSize);
643
+ } catch (err) {
644
+ if (Boom.isBoom(err)) {
645
+ throw err;
646
+ }
647
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
648
+ if (err.code) {
649
+ error.output.payload.code = err.code;
650
+ }
651
+ throw error;
652
+ }
653
+ },
654
+
655
+ options: {
656
+ description: 'List accounts',
657
+ notes: 'Lists registered accounts',
658
+ tags: ['api', 'account'],
659
+
660
+ validate: {
661
+ options: {
662
+ stripUnknown: false,
663
+ abortEarly: false,
664
+ convert: true
665
+ },
666
+ failAction,
667
+
668
+ query: Joi.object({
669
+ page: Joi.number()
670
+ .min(0)
671
+ .max(1024 * 1024)
672
+ .default(0)
673
+ .example(0)
674
+ .description('Page number (zero indexed, so use 0 for first page)')
675
+ .label('PageNumber'),
676
+ pageSize: Joi.number().min(1).max(1000).default(20).example(20).description('How many entries per page').label('PageSize'),
677
+ state: Joi.string()
678
+ .valid('init', 'connecting', 'connected', 'authenticationError', 'connectError', 'unset', 'disconnected')
679
+ .example('connected')
680
+ .description('Filter accounts by state')
681
+ .label('AccountState')
682
+ }).label('AccountsFilter')
683
+ },
684
+
685
+ response: {
686
+ schema: Joi.object({
687
+ total: Joi.number().example(120).description('How many matching entries').label('TotalNumber'),
688
+ page: Joi.number().example(0).description('Current page (0-based index)').label('PageNumber'),
689
+ pages: Joi.number().example(24).description('Total page count').label('PagesNumber'),
690
+
691
+ accounts: Joi.array()
692
+ .items(
693
+ Joi.object({
694
+ account: Joi.string().max(256).required().example('example').description('Account ID'),
695
+ name: Joi.string().max(256).example('My Email Account').description('Display name for the account'),
696
+ state: Joi.string()
697
+ .required()
698
+ .valid('init', 'connecting', 'connected', 'authenticationError', 'connectError', 'unset', 'disconnected')
699
+ .example('connected')
700
+ .description('Account state'),
701
+ syncTime: Joi.date().example('2021-02-17T13:43:18.860Z').description('Last sync time').iso(),
702
+ lastError: Joi.object({
703
+ response: Joi.string().example('Request to authentication server failed'),
704
+ serverResponseCode: Joi.string().example('HTTPRequestError')
705
+ })
706
+ .allow(null)
707
+ .label('AccountErrorEntry')
708
+ }).label('AccountResponseItem')
709
+ )
710
+ .label('AccountEntries')
711
+ }).label('AccountsFilterReponse'),
712
+ failAction: 'log'
713
+ }
714
+ }
715
+ });
716
+
717
+ server.route({
718
+ method: 'GET',
719
+ path: '/v1/account/{account}',
720
+
721
+ async handler(request) {
722
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
723
+ try {
724
+ let accountData = await accountObject.loadAccountData();
725
+
726
+ // remove secrets
727
+ for (let type of ['imap', 'smtp', 'oauth2']) {
728
+ if (accountData[type] && accountData[type].auth) {
729
+ for (let key of ['pass', 'accessToken', 'refreshToken']) {
730
+ if (key in accountData[type].auth) {
731
+ accountData[type].auth[key] = '******';
732
+ }
733
+ }
734
+ }
735
+
736
+ if (accountData[type]) {
737
+ for (let key of ['accessToken', 'refreshToken']) {
738
+ if (key in accountData[type]) {
739
+ accountData[type][key] = '******';
740
+ }
741
+ }
742
+ }
743
+ }
744
+
745
+ let result = {};
746
+
747
+ for (let key of ['account', 'name', 'copy', 'notifyFrom', 'imap', 'smtp', 'oauth2']) {
748
+ if (key in accountData) {
749
+ result[key] = accountData[key];
750
+ }
751
+ }
752
+
753
+ return result;
754
+ } catch (err) {
755
+ if (Boom.isBoom(err)) {
756
+ throw err;
757
+ }
758
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
759
+ if (err.code) {
760
+ error.output.payload.code = err.code;
761
+ }
762
+ throw error;
763
+ }
764
+ },
765
+ options: {
766
+ description: 'Get account info',
767
+ notes: 'Returns stored information about the account. Passwords are not included.',
768
+ tags: ['api', 'account'],
769
+
770
+ validate: {
771
+ options: {
772
+ stripUnknown: false,
773
+ abortEarly: false,
774
+ convert: true
775
+ },
776
+ failAction,
777
+
778
+ params: Joi.object({
779
+ account: Joi.string().max(256).required().example('example').description('Account ID')
780
+ })
781
+ },
782
+
783
+ response: {
784
+ schema: Joi.object({
785
+ account: Joi.string().max(256).required().example('example').description('Account ID'),
786
+
787
+ name: Joi.string().max(256).required().example('My Email Account').description('Display name for the account'),
788
+
789
+ copy: Joi.boolean().example(true).description('Copy submitted messages to Sent folder').default(true),
790
+ notifyFrom: Joi.date().example('2021-07-08T07:06:34.336Z').description('Notify messages from date').default('now').iso(),
791
+
792
+ imap: Joi.object(imapSchema).description('IMAP configuration').label('IMAP'),
793
+
794
+ smtp: Joi.object(smtpSchema).description('SMTP configuration').label('SMTP')
795
+ }).label('AccountResponse'),
796
+ failAction: 'log'
797
+ }
798
+ }
799
+ });
800
+
801
+ server.route({
802
+ method: 'GET',
803
+ path: '/v1/account/{account}/mailboxes',
804
+
805
+ async handler(request) {
806
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
807
+
808
+ try {
809
+ return { mailboxes: await accountObject.getMailboxListing() };
810
+ } catch (err) {
811
+ if (Boom.isBoom(err)) {
812
+ throw err;
813
+ }
814
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
815
+ if (err.code) {
816
+ error.output.payload.code = err.code;
817
+ }
818
+ throw error;
819
+ }
820
+ },
821
+
822
+ options: {
823
+ description: 'List mailboxes',
824
+ notes: 'Lists all available mailboxes',
825
+ tags: ['api', 'mailbox'],
826
+
827
+ validate: {
828
+ options: {
829
+ stripUnknown: false,
830
+ abortEarly: false,
831
+ convert: true
832
+ },
833
+ failAction,
834
+
835
+ params: Joi.object({
836
+ account: Joi.string().max(256).required().example('example').description('Account ID')
837
+ })
838
+ },
839
+
840
+ response: {
841
+ schema: Joi.object({
842
+ mailboxes: mailboxesSchema
843
+ }).label('MailboxesFilterReponse'),
844
+ failAction: 'log'
845
+ }
846
+ }
847
+ });
848
+
849
+ server.route({
850
+ method: 'POST',
851
+ path: '/v1/account/{account}/mailbox',
852
+
853
+ async handler(request) {
854
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
855
+
856
+ try {
857
+ return await accountObject.createMailbox(request.payload.path);
858
+ } catch (err) {
859
+ if (Boom.isBoom(err)) {
860
+ throw err;
861
+ }
862
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
863
+ if (err.code) {
864
+ error.output.payload.code = err.code;
865
+ }
866
+ throw error;
867
+ }
868
+ },
869
+
870
+ options: {
871
+ description: 'Create mailbox',
872
+ notes: 'Create new mailbox folder',
873
+ tags: ['api', 'mailbox'],
874
+
875
+ validate: {
876
+ options: {
877
+ stripUnknown: false,
878
+ abortEarly: false,
879
+ convert: true
880
+ },
881
+ failAction,
882
+
883
+ params: Joi.object({
884
+ account: Joi.string().max(256).example('example').required().description('Account ID')
885
+ }),
886
+
887
+ payload: Joi.object({
888
+ path: Joi.array()
889
+ .items(Joi.string().max(256))
890
+ .example(['Parent folder', 'Subfolder'])
891
+ .description('Mailbox path as an array. If account is namespaced then namespace prefix is added by default.')
892
+ .label('MailboxPath')
893
+ }).label('CreateMailbox')
894
+ },
895
+
896
+ response: {
897
+ schema: Joi.object({
898
+ path: Joi.string().required().example('Kalender/S&APw-nnip&AOQ-evad').description('Full path to mailbox').label('MailboxPath'),
899
+ mailboxId: Joi.string().example('1439876283476').description('Mailbox ID (if server has support)').label('MailboxId'),
900
+ created: Joi.boolean().example(true).description('Was the mailbox created')
901
+ }).label('CreateMailboxReponse'),
902
+ failAction: 'log'
903
+ }
904
+ }
905
+ });
906
+
907
+ server.route({
908
+ method: 'DELETE',
909
+ path: '/v1/account/{account}/mailbox',
910
+
911
+ async handler(request) {
912
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
913
+
914
+ try {
915
+ return await accountObject.deleteMailbox(request.query.path);
916
+ } catch (err) {
917
+ if (Boom.isBoom(err)) {
918
+ throw err;
919
+ }
920
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
921
+ if (err.code) {
922
+ error.output.payload.code = err.code;
923
+ }
924
+ throw error;
925
+ }
926
+ },
927
+
928
+ options: {
929
+ description: 'Delete mailbox',
930
+ notes: 'Delete existing mailbox folder',
931
+ tags: ['api', 'mailbox'],
932
+
933
+ validate: {
934
+ options: {
935
+ stripUnknown: false,
936
+ abortEarly: false,
937
+ convert: true
938
+ },
939
+ failAction,
940
+
941
+ params: Joi.object({
942
+ account: Joi.string().max(256).required().example('example').description('Account ID')
943
+ }),
944
+
945
+ query: Joi.object({
946
+ path: Joi.string().required().example('My Outdated Mail').description('Mailbox folder path to delete').label('MailboxPath')
947
+ }).label('DeleteMailbox')
948
+ },
949
+
950
+ response: {
951
+ schema: Joi.object({
952
+ path: Joi.string().required().example('Kalender/S&APw-nnip&AOQ-evad').description('Full path to mailbox').label('MailboxPath'),
953
+ deleted: Joi.boolean().example(true).description('Was the mailbox deleted')
954
+ }).label('DeleteMailboxReponse'),
955
+ failAction: 'log'
956
+ }
957
+ }
958
+ });
959
+
960
+ server.route({
961
+ method: 'GET',
962
+ path: '/v1/account/{account}/message/{message}/source',
963
+
964
+ async handler(request) {
965
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
966
+
967
+ try {
968
+ return await accountObject.getRawMessage(request.params.message);
969
+ } catch (err) {
970
+ if (Boom.isBoom(err)) {
971
+ throw err;
972
+ }
973
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
974
+ if (err.code) {
975
+ error.output.payload.code = err.code;
976
+ }
977
+ throw error;
978
+ }
979
+ },
980
+ options: {
981
+ description: 'Download raw message',
982
+ notes: 'Fetches raw message as a stream',
983
+ tags: ['api', 'message'],
984
+
985
+ validate: {
986
+ options: {
987
+ stripUnknown: false,
988
+ abortEarly: false,
989
+ convert: true
990
+ },
991
+ failAction,
992
+
993
+ params: Joi.object({
994
+ account: Joi.string().max(256).required().example('example').description('Account ID'),
995
+ message: Joi.string().base64({ paddingRequired: false, urlSafe: true }).max(256).example('AAAAAQAACnA').required().description('Message ID')
996
+ }).label('RawMessageRequest')
997
+ } /*,
998
+
999
+ response: {
1000
+ schema: Joi.binary().example('MIME-Version: 1.0...').description('RFC822 formatted email').label('RawMessageResponse'),
1001
+ failAction: 'log'
1002
+ }
1003
+ */
1004
+ }
1005
+ });
1006
+
1007
+ server.route({
1008
+ method: 'GET',
1009
+ path: '/v1/account/{account}/attachment/{attachment}',
1010
+
1011
+ async handler(request) {
1012
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
1013
+
1014
+ try {
1015
+ return await accountObject.getAttachment(request.params.attachment);
1016
+ } catch (err) {
1017
+ if (Boom.isBoom(err)) {
1018
+ throw err;
1019
+ }
1020
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
1021
+ if (err.code) {
1022
+ error.output.payload.code = err.code;
1023
+ }
1024
+ throw error;
1025
+ }
1026
+ },
1027
+ options: {
1028
+ description: 'Download attachment',
1029
+ notes: 'Fetches attachment file as a binary stream',
1030
+ tags: ['api', 'message'],
1031
+
1032
+ validate: {
1033
+ options: {
1034
+ stripUnknown: false,
1035
+ abortEarly: false,
1036
+ convert: true
1037
+ },
1038
+ failAction,
1039
+
1040
+ params: Joi.object({
1041
+ account: Joi.string().max(256).required().example('example').description('Account ID'),
1042
+ attachment: Joi.string()
1043
+ .base64({ paddingRequired: false, urlSafe: true })
1044
+ .max(256)
1045
+ .required()
1046
+ .example('AAAAAQAACnAcde')
1047
+ .description('Attachment ID')
1048
+ })
1049
+ }
1050
+ }
1051
+ });
1052
+
1053
+ server.route({
1054
+ method: 'GET',
1055
+ path: '/v1/account/{account}/message/{message}',
1056
+
1057
+ async handler(request) {
1058
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
1059
+
1060
+ try {
1061
+ return await accountObject.getMessage(request.params.message, request.query);
1062
+ } catch (err) {
1063
+ if (Boom.isBoom(err)) {
1064
+ throw err;
1065
+ }
1066
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
1067
+ if (err.code) {
1068
+ error.output.payload.code = err.code;
1069
+ }
1070
+ throw error;
1071
+ }
1072
+ },
1073
+ options: {
1074
+ description: 'Get message information',
1075
+ notes: 'Returns details of a specific message. By default text content is not included, use textType value to force retrieving text',
1076
+ tags: ['api', 'message'],
1077
+
1078
+ validate: {
1079
+ options: {
1080
+ stripUnknown: false,
1081
+ abortEarly: false,
1082
+ convert: true
1083
+ },
1084
+ failAction,
1085
+
1086
+ query: Joi.object({
1087
+ maxBytes: Joi.number()
1088
+ .min(0)
1089
+ .max(1024 * 1024 * 1024)
1090
+ .example(5 * 1025 * 1024)
1091
+ .description('Max length of text content'),
1092
+ textType: Joi.string()
1093
+ .lowercase()
1094
+ .valid('html', 'plain', '*')
1095
+ .example('*')
1096
+ .description('Which text content to return, use * for all. By default text content is not returned.')
1097
+ }),
1098
+
1099
+ params: Joi.object({
1100
+ account: Joi.string().max(256).required().example('example').description('Account ID'),
1101
+ message: Joi.string().base64({ paddingRequired: false, urlSafe: true }).max(256).required().example('AAAAAQAACnA').description('Message ID')
1102
+ })
1103
+ },
1104
+
1105
+ response: {
1106
+ schema: messageDetailsSchema,
1107
+ failAction: 'log'
1108
+ }
1109
+ }
1110
+ });
1111
+
1112
+ server.route({
1113
+ method: 'POST',
1114
+ path: '/v1/account/{account}/message',
1115
+
1116
+ async handler(request) {
1117
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
1118
+
1119
+ try {
1120
+ return await accountObject.uploadMessage(request.payload);
1121
+ } catch (err) {
1122
+ if (Boom.isBoom(err)) {
1123
+ throw err;
1124
+ }
1125
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
1126
+ if (err.code) {
1127
+ error.output.payload.code = err.code;
1128
+ }
1129
+ throw error;
1130
+ }
1131
+ },
1132
+ options: {
1133
+ payload: {
1134
+ // allow message uploads up to 50MB
1135
+ // TODO: should it be configurable instead?
1136
+ maxBytes: 50 * 1024 * 1024
1137
+ },
1138
+
1139
+ description: 'Upload message',
1140
+ notes: 'Upload a message structure, compile it into an EML file and store it into selected mailbox.',
1141
+ tags: ['api', 'message'],
1142
+
1143
+ validate: {
1144
+ options: {
1145
+ stripUnknown: false,
1146
+ abortEarly: false,
1147
+ convert: true
1148
+ },
1149
+ failAction,
1150
+
1151
+ params: Joi.object({
1152
+ account: Joi.string().max(256).required().example('example').description('Account ID')
1153
+ }),
1154
+
1155
+ payload: Joi.object({
1156
+ path: Joi.string().required().example('INBOX').description('Target mailbox folder path'),
1157
+
1158
+ flags: Joi.array().items(Joi.string().max(128)).example(['\\Seen', '\\Draft']).default([]).description('Message flags').label('Flags'),
1159
+
1160
+ reference: Joi.object({
1161
+ message: Joi.string()
1162
+ .base64({ paddingRequired: false, urlSafe: true })
1163
+ .max(256)
1164
+ .required()
1165
+ .example('AAAAAQAACnA')
1166
+ .description('Referenced message ID'),
1167
+ action: Joi.string().lowercase().valid('forward', 'reply').example('reply').default('reply')
1168
+ })
1169
+ .description('Message reference for a reply or a forward. This is EmailEngine specific ID, not Message-ID header value.')
1170
+ .label('MessageReference'),
1171
+
1172
+ from: addressSchema.required().example({ name: 'From Me', address: 'sender@example.com' }),
1173
+
1174
+ to: Joi.array()
1175
+ .items(addressSchema)
1176
+ .description('List of addresses')
1177
+ .example([{ address: 'recipient@example.com' }])
1178
+ .label('AddressList'),
1179
+
1180
+ cc: Joi.array().items(addressSchema).description('List of addresses').label('AddressList'),
1181
+
1182
+ bcc: Joi.array().items(addressSchema).description('List of addresses').label('AddressList'),
1183
+
1184
+ subject: Joi.string().max(1024).example('What a wonderful message').description('Message subject'),
1185
+
1186
+ text: Joi.string().max(MAX_ATTACHMENT_SIZE).example('Hello from myself!').description('Message Text'),
1187
+
1188
+ html: Joi.string().max(MAX_ATTACHMENT_SIZE).example('<p>Hello from myself!</p>').description('Message HTML'),
1189
+
1190
+ attachments: Joi.array()
1191
+ .items(
1192
+ Joi.object({
1193
+ filename: Joi.string().max(256).example('transparent.gif'),
1194
+ content: Joi.string()
1195
+ .base64()
1196
+ .max(MAX_ATTACHMENT_SIZE)
1197
+ .required()
1198
+ .example('R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=')
1199
+ .description('Base64 formatted attachment file'),
1200
+ contentType: Joi.string().lowercase().max(256).example('image/gif'),
1201
+ contentDisposition: Joi.string().lowercase().valid('inline', 'attachment'),
1202
+ cid: Joi.string().max(256).example('unique-image-id@localhost').description('Content-ID value for embedded images'),
1203
+ encoding: Joi.string().valid('base64').default('base64')
1204
+ }).label('Attachment')
1205
+ )
1206
+ .description('List of attachments')
1207
+ .label('AttachmentList'),
1208
+
1209
+ messageId: Joi.string().max(74).example('<test123@example.com>').description('Message ID'),
1210
+ headers: Joi.object().description('Custom Headers')
1211
+ }).label('MessageUpload')
1212
+ },
1213
+
1214
+ response: {
1215
+ schema: Joi.object({
1216
+ id: Joi.string()
1217
+ .example('AAAAAgAACrI')
1218
+ .description('Message ID. NB! This and other fields might not be present if server did not provide enough information')
1219
+ .label('MessageAppendId'),
1220
+ path: Joi.string().example('INBOX').description('Folder this message was uploaded to').label('MessageAppendPath'),
1221
+ uid: Joi.number().example(12345).description('UID of uploaded message'),
1222
+ seq: Joi.number().example(12345).description('Sequence number of uploaded message')
1223
+ }).label('MessageUploadResponse'),
1224
+ failAction: 'log'
1225
+ }
1226
+ }
1227
+ });
1228
+
1229
+ server.route({
1230
+ method: 'PUT',
1231
+ path: '/v1/account/{account}/message/{message}',
1232
+
1233
+ async handler(request) {
1234
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
1235
+
1236
+ try {
1237
+ return await accountObject.updateMessage(request.params.message, request.payload);
1238
+ } catch (err) {
1239
+ if (Boom.isBoom(err)) {
1240
+ throw err;
1241
+ }
1242
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
1243
+ if (err.code) {
1244
+ error.output.payload.code = err.code;
1245
+ }
1246
+ throw error;
1247
+ }
1248
+ },
1249
+ options: {
1250
+ description: 'Update message',
1251
+ notes: 'Update message information. Mainly this means changing message flag values',
1252
+ tags: ['api', 'message'],
1253
+
1254
+ validate: {
1255
+ options: {
1256
+ stripUnknown: false,
1257
+ abortEarly: false,
1258
+ convert: true
1259
+ },
1260
+ failAction,
1261
+
1262
+ params: Joi.object({
1263
+ account: Joi.string().max(256).required().example('example').description('Account ID'),
1264
+ message: Joi.string().max(256).required().example('AAAAAQAACnA').description('Message ID')
1265
+ }),
1266
+
1267
+ payload: Joi.object({
1268
+ flags: Joi.object({
1269
+ add: Joi.array().items(Joi.string().max(128)).description('Add new flags').example(['\\Seen']).label('AddFlags'),
1270
+ delete: Joi.array().items(Joi.string().max(128)).description('Delete specific flags').example(['\\Flagged']).label('DeleteFlags'),
1271
+ set: Joi.array().items(Joi.string().max(128)).description('Override all flags').example(['\\Seen', '\\Flagged']).label('SetFlags')
1272
+ })
1273
+ .description('Flag updates')
1274
+ .label('FlagUpdate'),
1275
+
1276
+ labels: Joi.object({
1277
+ add: Joi.array().items(Joi.string().max(128)).description('Add new labels').example(['Some label']).label('AddLabels'),
1278
+ delete: Joi.array().items(Joi.string().max(128)).description('Delete specific labels').example(['Some label']).label('DeleteLabels'),
1279
+ set: Joi.array()
1280
+ .items(Joi.string().max(128))
1281
+ .description('Override all labels')
1282
+ .example(['First label', 'Second label'])
1283
+ .label('SetLabels')
1284
+ })
1285
+ .description('Label updates')
1286
+ .label('LabelUpdate')
1287
+ }).label('MessageUpdate')
1288
+ },
1289
+ response: {
1290
+ schema: Joi.object({
1291
+ flags: Joi.object({
1292
+ add: Joi.boolean().example(true),
1293
+ delete: Joi.boolean().example(false),
1294
+ set: Joi.boolean().example(false)
1295
+ }).label('FlagResponse'),
1296
+ labels: Joi.object({
1297
+ add: Joi.boolean().example(true),
1298
+ delete: Joi.boolean().example(false),
1299
+ set: Joi.boolean().example(false)
1300
+ }).label('FlagResponse')
1301
+ }).label('MessageUpdateReponse'),
1302
+ failAction: 'log'
1303
+ }
1304
+ }
1305
+ });
1306
+
1307
+ server.route({
1308
+ method: 'PUT',
1309
+ path: '/v1/account/{account}/message/{message}/move',
1310
+
1311
+ async handler(request) {
1312
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
1313
+
1314
+ try {
1315
+ return await accountObject.moveMessage(request.params.message, request.payload);
1316
+ } catch (err) {
1317
+ if (Boom.isBoom(err)) {
1318
+ throw err;
1319
+ }
1320
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
1321
+ if (err.code) {
1322
+ error.output.payload.code = err.code;
1323
+ }
1324
+ throw error;
1325
+ }
1326
+ },
1327
+ options: {
1328
+ description: 'Move message',
1329
+ notes: 'Move message to another folder',
1330
+ tags: ['api', 'message'],
1331
+
1332
+ validate: {
1333
+ options: {
1334
+ stripUnknown: false,
1335
+ abortEarly: false,
1336
+ convert: true
1337
+ },
1338
+ failAction,
1339
+
1340
+ params: Joi.object({
1341
+ account: Joi.string().max(256).required().example('example').description('Account ID'),
1342
+ message: Joi.string().max(256).required().example('AAAAAQAACnA').description('Message ID')
1343
+ }),
1344
+
1345
+ payload: Joi.object({
1346
+ path: Joi.string().required().example('INBOX').description('Target mailbox folder path')
1347
+ }).label('MessageMove')
1348
+ },
1349
+
1350
+ response: {
1351
+ schema: Joi.object({
1352
+ path: Joi.string().required().example('INBOX').description('Target mailbox folder path'),
1353
+ id: Joi.string().max(256).required().example('AAAAAQAACnA').description('Message ID'),
1354
+ uid: Joi.number().example(12345).description('UID of moved message')
1355
+ }).label('MessageMoveResponse'),
1356
+ failAction: 'log'
1357
+ }
1358
+ }
1359
+ });
1360
+
1361
+ server.route({
1362
+ method: 'DELETE',
1363
+ path: '/v1/account/{account}/message/{message}',
1364
+
1365
+ async handler(request) {
1366
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
1367
+
1368
+ try {
1369
+ return await accountObject.deleteMessage(request.params.message);
1370
+ } catch (err) {
1371
+ if (Boom.isBoom(err)) {
1372
+ throw err;
1373
+ }
1374
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
1375
+ if (err.code) {
1376
+ error.output.payload.code = err.code;
1377
+ }
1378
+ throw error;
1379
+ }
1380
+ },
1381
+ options: {
1382
+ description: 'Delete message',
1383
+ notes: 'Move message to Trash or delete it if already in Trash',
1384
+ tags: ['api', 'message'],
1385
+
1386
+ validate: {
1387
+ options: {
1388
+ stripUnknown: false,
1389
+ abortEarly: false,
1390
+ convert: true
1391
+ },
1392
+ failAction,
1393
+
1394
+ params: Joi.object({
1395
+ account: Joi.string().max(256).required().example('example').description('Account ID'),
1396
+ message: Joi.string().max(256).required().example('AAAAAQAACnA').description('Message ID')
1397
+ }).label('MessageDelete')
1398
+ },
1399
+ response: {
1400
+ schema: Joi.object({
1401
+ deleted: Joi.boolean().example(true).description('Present if message was actualy deleted'),
1402
+ moved: Joi.object({
1403
+ destination: Joi.string().required().example('Trash').description('Trash folder path').label('TrashPath'),
1404
+ message: Joi.string().required().example('AAAAAwAAAWg').description('Message ID in Trash').label('TrashMessageId')
1405
+ }).description('Present if message was moved to Trash')
1406
+ }).label('MessageDeleteReponse'),
1407
+ failAction: 'log'
1408
+ }
1409
+ }
1410
+ });
1411
+
1412
+ server.route({
1413
+ method: 'GET',
1414
+ path: '/v1/account/{account}/text/{text}',
1415
+
1416
+ async handler(request) {
1417
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
1418
+
1419
+ try {
1420
+ return await accountObject.getText(request.params.text, request.query);
1421
+ } catch (err) {
1422
+ if (Boom.isBoom(err)) {
1423
+ throw err;
1424
+ }
1425
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
1426
+ if (err.code) {
1427
+ error.output.payload.code = err.code;
1428
+ }
1429
+ throw error;
1430
+ }
1431
+ },
1432
+ options: {
1433
+ description: 'Retrieve message text',
1434
+ notes: 'Retrieves message text',
1435
+ tags: ['api', 'message'],
1436
+
1437
+ validate: {
1438
+ options: {
1439
+ stripUnknown: false,
1440
+ abortEarly: false,
1441
+ convert: true
1442
+ },
1443
+ failAction,
1444
+
1445
+ query: Joi.object({
1446
+ maxBytes: Joi.number()
1447
+ .min(0)
1448
+ .max(1024 * 1024 * 1024)
1449
+ .example(MAX_ATTACHMENT_SIZE)
1450
+ .description('Max length of text content'),
1451
+ textType: Joi.string()
1452
+ .lowercase()
1453
+ .valid('html', 'plain', '*')
1454
+ .default('*')
1455
+ .example('*')
1456
+ .description('Which text content to return, use * for all. By default all contents are returned.')
1457
+ }),
1458
+
1459
+ params: Joi.object({
1460
+ account: Joi.string().max(256).required().example('example').description('Account ID'),
1461
+ text: Joi.string()
1462
+ .base64({ paddingRequired: false, urlSafe: true })
1463
+ .max(256)
1464
+ .required()
1465
+ .example('AAAAAQAACnAcdfaaN')
1466
+ .description('Message text ID')
1467
+ }).label('Text')
1468
+ },
1469
+
1470
+ response: {
1471
+ schema: Joi.object({
1472
+ plain: Joi.string().example('Hello world').description('Plaintext content'),
1473
+ html: Joi.string().example('<p>Hello world</p>').description('HTML content'),
1474
+ hasMore: Joi.boolean().example(false).description('Is the current text output capped or not')
1475
+ }).label('TextResponse'),
1476
+ failAction: 'log'
1477
+ }
1478
+ }
1479
+ });
1480
+
1481
+ server.route({
1482
+ method: 'GET',
1483
+ path: '/v1/account/{account}/messages',
1484
+
1485
+ async handler(request) {
1486
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
1487
+ try {
1488
+ return await accountObject.listMessages(request.query);
1489
+ } catch (err) {
1490
+ if (Boom.isBoom(err)) {
1491
+ throw err;
1492
+ }
1493
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
1494
+ if (err.code) {
1495
+ error.output.payload.code = err.code;
1496
+ }
1497
+ throw error;
1498
+ }
1499
+ },
1500
+ options: {
1501
+ description: 'List messages in a folder',
1502
+ notes: 'Lists messages in a mailbox folder',
1503
+ tags: ['api', 'message'],
1504
+
1505
+ validate: {
1506
+ options: {
1507
+ stripUnknown: false,
1508
+ abortEarly: false,
1509
+ convert: true
1510
+ },
1511
+ failAction,
1512
+
1513
+ params: Joi.object({
1514
+ account: Joi.string().max(256).required().example('example').description('Account ID').label('AccountId')
1515
+ }),
1516
+
1517
+ query: Joi.object({
1518
+ path: Joi.string().required().example('INBOX').description('Mailbox folder path').label('Path'),
1519
+ page: Joi.number()
1520
+ .min(0)
1521
+ .max(1024 * 1024)
1522
+ .default(0)
1523
+ .example(0)
1524
+ .description('Page number (zero indexed, so use 0 for first page)')
1525
+ .label('PageNumber'),
1526
+ pageSize: Joi.number().min(1).max(1000).default(20).example(20).description('How many entries per page').label('PageSize')
1527
+ }).label('MessageQuery')
1528
+ },
1529
+
1530
+ response: {
1531
+ schema: messageListSchema,
1532
+ failAction: 'log'
1533
+ }
1534
+ }
1535
+ });
1536
+
1537
+ server.route({
1538
+ method: 'POST',
1539
+ path: '/v1/account/{account}/search',
1540
+
1541
+ async handler(request) {
1542
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
1543
+ try {
1544
+ return await accountObject.listMessages(Object.assign(request.query, request.payload));
1545
+ } catch (err) {
1546
+ if (Boom.isBoom(err)) {
1547
+ throw err;
1548
+ }
1549
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
1550
+ if (err.code) {
1551
+ error.output.payload.code = err.code;
1552
+ }
1553
+ throw error;
1554
+ }
1555
+ },
1556
+ options: {
1557
+ description: 'Search for messages',
1558
+ notes: 'Filter messages from a mailbox folder by search options. Search is performed against a specific foldera and not for the entire account.',
1559
+ tags: ['api', 'message'],
1560
+
1561
+ validate: {
1562
+ options: {
1563
+ stripUnknown: false,
1564
+ abortEarly: false,
1565
+ convert: true
1566
+ },
1567
+ failAction,
1568
+
1569
+ params: Joi.object({
1570
+ account: Joi.string().max(256).required().example('example').description('Account ID')
1571
+ }),
1572
+
1573
+ query: Joi.object({
1574
+ path: Joi.string().required().example('INBOX').description('Mailbox folder path'),
1575
+ page: Joi.number()
1576
+ .min(0)
1577
+ .max(1024 * 1024)
1578
+ .default(0)
1579
+ .example(0)
1580
+ .description('Page number (zero indexed, so use 0 for first page)'),
1581
+ pageSize: Joi.number().min(1).max(1000).default(20).example(20).description('How many entries per page')
1582
+ }),
1583
+
1584
+ payload: Joi.object({
1585
+ search: Joi.object({
1586
+ seq: Joi.string().max(256).description('Sequence number range'),
1587
+
1588
+ answered: Joi.boolean()
1589
+ .truthy('Y', 'true', '1')
1590
+ .falsy('N', 'false', 0)
1591
+ .description('Check if message is answered or not')
1592
+ .label('AnsweredFlag'),
1593
+ deleted: Joi.boolean()
1594
+ .truthy('Y', 'true', '1')
1595
+ .falsy('N', 'false', 0)
1596
+ .description('Check if message is marked for being deleted or not')
1597
+ .label('DeletedFlag'),
1598
+ draft: Joi.boolean().truthy('Y', 'true', '1').falsy('N', 'false', 0).description('Check if message is a draft').label('DraftFlag'),
1599
+ unseen: Joi.boolean()
1600
+ .truthy('Y', 'true', '1')
1601
+ .falsy('N', 'false', 0)
1602
+ .description('Check if message is marked as unseen or not')
1603
+ .label('UnseenFlag'),
1604
+ flagged: Joi.boolean()
1605
+ .truthy('Y', 'true', '1')
1606
+ .falsy('N', 'false', 0)
1607
+ .description('Check if message is flagged or not')
1608
+ .label('Flagged'),
1609
+ seen: Joi.boolean()
1610
+ .truthy('Y', 'true', '1')
1611
+ .falsy('N', 'false', 0)
1612
+ .description('Check if message is marked as seen or not')
1613
+ .label('SeenFlag'),
1614
+
1615
+ from: Joi.string().max(256).description('Match From: header').label('From'),
1616
+ to: Joi.string().max(256).description('Match To: header').label('To'),
1617
+ cc: Joi.string().max(256).description('Match Cc: header').label('Cc'),
1618
+ bcc: Joi.string().max(256).description('Match Bcc: header').label('Bcc'),
1619
+
1620
+ body: Joi.string().max(256).description('Match text body').label('MessageBody'),
1621
+ subject: Joi.string().max(256).description('Match message subject').label('Subject'),
1622
+
1623
+ larger: Joi.number()
1624
+ .min(0)
1625
+ .max(1024 * 1024 * 1024)
1626
+ .description('Matches messages larger than value')
1627
+ .label('MessageLarger'),
1628
+
1629
+ smaller: Joi.number()
1630
+ .min(0)
1631
+ .max(1024 * 1024 * 1024)
1632
+ .description('Matches messages smaller than value')
1633
+ .label('MessageSmaller'),
1634
+
1635
+ uid: Joi.string().max(256).description('UID range').label('UIDRange'),
1636
+
1637
+ modseq: Joi.number().min(0).description('Matches messages with modseq higher than value').label('ModseqLarger'),
1638
+
1639
+ before: Joi.date().description('Matches messages received before date').label('EnvelopeBefore'),
1640
+ since: Joi.date().description('Matches messages received after date').label('EnvelopeSince'),
1641
+
1642
+ sentBefore: Joi.date().description('Matches messages sent before date').label('HeaderBefore'),
1643
+ sentSince: Joi.date().description('Matches messages sent after date').label('HeaderSince'),
1644
+
1645
+ emailId: Joi.string().max(256).description('Match specific Gmail unique email UD'),
1646
+ threadId: Joi.string().max(256).description('Match specific Gmail unique thread UD'),
1647
+
1648
+ header: Joi.object().unknown(true).description('Headers to match against').label('Headers')
1649
+ })
1650
+ .required()
1651
+ .description('Search query to filter messages')
1652
+ .label('SearchQuery')
1653
+ }).label('SearchQuery')
1654
+ },
1655
+
1656
+ response: {
1657
+ schema: messageListSchema,
1658
+ failAction: 'log'
1659
+ }
1660
+ }
1661
+ });
1662
+
1663
+ server.route({
1664
+ method: 'GET',
1665
+ path: '/v1/account/{account}/contacts',
1666
+
1667
+ async handler(request) {
1668
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
1669
+ try {
1670
+ return await accountObject.buildContacts();
1671
+ } catch (err) {
1672
+ if (Boom.isBoom(err)) {
1673
+ throw err;
1674
+ }
1675
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
1676
+ if (err.code) {
1677
+ error.output.payload.code = err.code;
1678
+ }
1679
+ throw error;
1680
+ }
1681
+ },
1682
+ options: {
1683
+ description: 'Builds a contact listing',
1684
+ notes: 'Builds a contact listings from email addresses. For larger mailboxes this could take a lot of time.',
1685
+ tags: [/*'api', */ 'experimental'],
1686
+
1687
+ validate: {
1688
+ options: {
1689
+ stripUnknown: false,
1690
+ abortEarly: false,
1691
+ convert: true
1692
+ },
1693
+ failAction,
1694
+
1695
+ params: Joi.object({
1696
+ account: Joi.string().max(256).required().example('example').description('Account ID')
1697
+ })
1698
+ }
1699
+ }
1700
+ });
1701
+
1702
+ server.route({
1703
+ method: 'POST',
1704
+ path: '/v1/account/{account}/submit',
1705
+
1706
+ async handler(request) {
1707
+ let accountObject = new Account({ redis, account: request.params.account, call, secret: await getSecret() });
1708
+
1709
+ try {
1710
+ return await accountObject.queueMessage(request.payload);
1711
+ } catch (err) {
1712
+ if (Boom.isBoom(err)) {
1713
+ throw err;
1714
+ }
1715
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
1716
+ if (err.code) {
1717
+ error.output.payload.code = err.code;
1718
+ }
1719
+ throw error;
1720
+ }
1721
+ },
1722
+ options: {
1723
+ payload: {
1724
+ // allow message uploads up to 50MB
1725
+ // TODO: should it be configurable instead?
1726
+ maxBytes: 50 * 1024 * 1024
1727
+ },
1728
+
1729
+ description: 'Submit message for delivery',
1730
+ notes: 'Submit message for delivery. If reference message ID is provided then EmailEngine adds all headers and flags required for a reply/forward automatically.',
1731
+ tags: ['api', 'submit'],
1732
+
1733
+ validate: {
1734
+ options: {
1735
+ stripUnknown: false,
1736
+ abortEarly: false,
1737
+ convert: true
1738
+ },
1739
+ failAction,
1740
+
1741
+ params: Joi.object({
1742
+ account: Joi.string().max(256).required().example('example').description('Account ID')
1743
+ }),
1744
+
1745
+ payload: Joi.object({
1746
+ reference: Joi.object({
1747
+ message: Joi.string()
1748
+ .base64({ paddingRequired: false, urlSafe: true })
1749
+ .max(256)
1750
+ .required()
1751
+ .example('AAAAAQAACnA')
1752
+ .description('Referenced message ID'),
1753
+ action: Joi.string().lowercase().valid('forward', 'reply').example('reply').default('reply')
1754
+ })
1755
+ .description('Message reference for a reply or a forward. This is EmailEngine specific ID, not Message-ID header value.')
1756
+ .label('MessageReference'),
1757
+
1758
+ envelope: Joi.object({
1759
+ from: Joi.string().email().allow('').example('sender@example.com'),
1760
+ to: Joi.array().items(Joi.string().email().required().example('recipient@example.com'))
1761
+ })
1762
+ .description('Optional SMTP envelope. If not set then derived from message headers.')
1763
+ .label('SMTPEnvelope'),
1764
+
1765
+ from: addressSchema.example({ name: 'From Me', address: 'sender@example.com' }),
1766
+
1767
+ to: Joi.array()
1768
+ .items(addressSchema)
1769
+ .description('List of addresses')
1770
+ .example([{ address: 'recipient@example.com' }])
1771
+ .label('ToAddressList'),
1772
+
1773
+ cc: Joi.array().items(addressSchema).description('List of addresses').label('CcAddressList'),
1774
+
1775
+ bcc: Joi.array().items(addressSchema).description('List of addresses').label('BccAddressList'),
1776
+
1777
+ raw: Joi.string()
1778
+ .base64()
1779
+ .max(MAX_ATTACHMENT_SIZE)
1780
+ .example('TUlNRS1WZXJzaW9uOiAxLjANClN1YmplY3Q6IGhlbGxvIHdvcmxkDQoNCkhlbGxvIQ0K')
1781
+ .description(
1782
+ 'Base64 encoded email message in rfc822 format. If you provide other keys as well then these will override the values in the raw message.'
1783
+ )
1784
+ .label('RFC822Raw'),
1785
+
1786
+ subject: Joi.string().max(1024).example('What a wonderful message').description('Message subject'),
1787
+
1788
+ text: Joi.string().max(MAX_ATTACHMENT_SIZE).example('Hello from myself!').description('Message Text'),
1789
+
1790
+ html: Joi.string().max(MAX_ATTACHMENT_SIZE).example('<p>Hello from myself!</p>').description('Message HTML'),
1791
+
1792
+ attachments: Joi.array()
1793
+ .items(
1794
+ Joi.object({
1795
+ filename: Joi.string().max(256).example('transparent.gif'),
1796
+ content: Joi.string()
1797
+ .base64()
1798
+ .max(MAX_ATTACHMENT_SIZE)
1799
+ .required()
1800
+ .example('R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=')
1801
+ .description('Base64 formatted attachment file'),
1802
+ contentType: Joi.string().lowercase().max(256).example('image/gif'),
1803
+ contentDisposition: Joi.string().lowercase().valid('inline', 'attachment'),
1804
+ cid: Joi.string().max(256).example('unique-image-id@localhost').description('Content-ID value for embedded images'),
1805
+ encoding: Joi.string().valid('base64').default('base64')
1806
+ }).label('Attachment')
1807
+ )
1808
+ .description('List of attachments')
1809
+ .label('AttachmentList'),
1810
+
1811
+ messageId: Joi.string().max(74).example('<test123@example.com>').description('Message ID'),
1812
+ headers: Joi.object().description('Custom Headers'),
1813
+
1814
+ sendAt: Joi.date().example('2021-07-08T07:06:34.336Z').description('Send message at specified time').iso()
1815
+ })
1816
+ .oxor('raw', 'html')
1817
+ .oxor('raw', 'text')
1818
+ .oxor('raw', 'text')
1819
+ .oxor('raw', 'attachments')
1820
+ .label('SubmitMessage')
1821
+ },
1822
+
1823
+ response: {
1824
+ schema: Joi.object({
1825
+ response: Joi.string().example('Queued for delivery'),
1826
+ messageId: Joi.string().example('<a2184d08-a470-fec6-a493-fa211a3756e9@example.com>').description('Message-ID header value'),
1827
+ sendAt: Joi.date().example('2021-07-08T07:06:34.336Z').description('Scheduled send time'),
1828
+ queueId: Joi.string().example('d41f0423195f271f').description('Queue identifier for scheduled email')
1829
+ }).label('SubmitMessageResponse'),
1830
+ failAction: 'log'
1831
+ }
1832
+ }
1833
+ });
1834
+
1835
+ server.route({
1836
+ method: 'GET',
1837
+ path: '/v1/settings',
1838
+
1839
+ async handler(request) {
1840
+ let values = {};
1841
+ for (let key of Object.keys(request.query)) {
1842
+ if (request.query[key]) {
1843
+ if (key === 'eventTypes') {
1844
+ values[key] = Object.keys(consts)
1845
+ .map(key => {
1846
+ if (/_NOTIFY?/.test(key)) {
1847
+ return consts[key];
1848
+ }
1849
+ return false;
1850
+ })
1851
+ .map(key => key);
1852
+ continue;
1853
+ }
1854
+
1855
+ let value = await settings.get(key);
1856
+
1857
+ if (settings.encryptedKeys.includes(key)) {
1858
+ // do not reveal secret values
1859
+ // instead show boolean value true if value is set, or false if it's not
1860
+ value = value ? true : false;
1861
+ }
1862
+
1863
+ values[key] = value;
1864
+ }
1865
+ }
1866
+ return values;
1867
+ },
1868
+ options: {
1869
+ description: 'List specific settings',
1870
+ notes: 'List setting values for specific keys',
1871
+ tags: ['api', 'settings'],
1872
+
1873
+ validate: {
1874
+ options: {
1875
+ stripUnknown: false,
1876
+ abortEarly: false,
1877
+ convert: true
1878
+ },
1879
+ failAction,
1880
+
1881
+ query: Joi.object(settingsQuerySchema).label('SettingsQuery')
1882
+ },
1883
+
1884
+ response: {
1885
+ schema: Joi.object(settingsSchema).label('SettingsQueryResponse'),
1886
+ failAction: 'log'
1887
+ }
1888
+ }
1889
+ });
1890
+
1891
+ server.route({
1892
+ method: 'POST',
1893
+ path: '/v1/settings',
1894
+
1895
+ async handler(request) {
1896
+ let updated = [];
1897
+ for (let key of Object.keys(request.payload)) {
1898
+ switch (key) {
1899
+ case 'logs': {
1900
+ let logs = request.payload.logs;
1901
+ let resetLoggedAccounts = logs.resetLoggedAccounts;
1902
+ delete logs.resetLoggedAccounts;
1903
+ if (resetLoggedAccounts && logs.accounts && logs.accounts.length) {
1904
+ for (let account of logs.accounts) {
1905
+ logger.info({ msg: 'Request reconnect for logging', account });
1906
+ try {
1907
+ await call({ cmd: 'update', account });
1908
+ } catch (err) {
1909
+ logger.error({ action: 'request_reconnect', account, err });
1910
+ }
1911
+ }
1912
+ }
1913
+ }
1914
+ }
1915
+
1916
+ await settings.set(key, request.payload[key]);
1917
+ updated.push(key);
1918
+ }
1919
+
1920
+ notify('settings', request.payload);
1921
+ return { updated };
1922
+ },
1923
+ options: {
1924
+ description: 'Set setting values',
1925
+ notes: 'Set setting values for specific keys',
1926
+ tags: ['api', 'settings'],
1927
+
1928
+ validate: {
1929
+ options: {
1930
+ stripUnknown: false,
1931
+ abortEarly: false,
1932
+ convert: true
1933
+ },
1934
+ failAction,
1935
+
1936
+ payload: Joi.object(settingsSchema).label('Settings')
1937
+ },
1938
+
1939
+ response: {
1940
+ schema: Joi.object({ updated: Joi.array().items(Joi.string().example('notifyHeaders')).description('List of updated setting keys') }).label(
1941
+ 'SettingsResponse'
1942
+ ),
1943
+ failAction: 'log'
1944
+ }
1945
+ }
1946
+ });
1947
+
1948
+ server.route({
1949
+ method: 'GET',
1950
+ path: '/v1/logs/{account}',
1951
+
1952
+ async handler(request) {
1953
+ return getLogs(request.params.account);
1954
+ },
1955
+ options: {
1956
+ description: 'Return IMAP logs for an account',
1957
+ notes: 'Output is a downloadable text file',
1958
+ tags: ['api', 'logs'],
1959
+
1960
+ validate: {
1961
+ options: {
1962
+ stripUnknown: false,
1963
+ abortEarly: false,
1964
+ convert: true
1965
+ },
1966
+ failAction,
1967
+
1968
+ params: Joi.object({
1969
+ account: Joi.string().max(256).required().example('example').description('Account ID')
1970
+ })
1971
+ }
1972
+ }
1973
+ });
1974
+
1975
+ server.route({
1976
+ method: 'GET',
1977
+ path: '/v1/stats',
1978
+
1979
+ async handler(request) {
1980
+ return await getStats(request.query.seconds);
1981
+ },
1982
+ options: {
1983
+ description: 'Return server stats',
1984
+ tags: ['api', 'stats'],
1985
+
1986
+ validate: {
1987
+ options: {
1988
+ stripUnknown: false,
1989
+ abortEarly: false,
1990
+ convert: true
1991
+ },
1992
+ failAction,
1993
+
1994
+ query: Joi.object({
1995
+ seconds: Joi.number()
1996
+ .empty('')
1997
+ .min(0)
1998
+ .max(consts.MAX_DAYS_STATS * 24 * 3600)
1999
+ .default(3600)
2000
+ .example(3600)
2001
+ .description('Duration for counters')
2002
+ .label('CounterSeconds')
2003
+ }).label('ServerStats')
2004
+ },
2005
+
2006
+ response: {
2007
+ schema: Joi.object({
2008
+ version: Joi.string().example(packageData.version).description('EmailEngine version number'),
2009
+ license: Joi.string().example(packageData.license).description('EmailEngine license'),
2010
+ accounts: Joi.number().example(26).description('Number of registered accounts'),
2011
+ connections: Joi.object({
2012
+ init: Joi.number().example(2).description('Accounts not yet initialized'),
2013
+ connected: Joi.number().example(8).description('Successfully connected accounts'),
2014
+ connecting: Joi.number().example(7).description('Connection is being established'),
2015
+ authenticationError: Joi.number().example(3).description('Authentication failed'),
2016
+ connectError: Joi.number().example(5).description('Connection failed due to technical error'),
2017
+ unset: Joi.number().example(0).description('Accounts without valid IMAP settings'),
2018
+ disconnected: Joi.number().example(1).description('IMAP connection was closed')
2019
+ })
2020
+ .description('Counts of accounts in different connection states')
2021
+ .label('ConnectionsStats'),
2022
+ counters: Joi.object().label('CounterStats')
2023
+ }).label('SettingsResponse'),
2024
+ failAction: 'log'
2025
+ }
2026
+ }
2027
+ });
2028
+
2029
+ server.route({
2030
+ method: 'POST',
2031
+ path: '/v1/verifyAccount',
2032
+
2033
+ async handler(request) {
2034
+ try {
2035
+ return await verifyAccountInfo(request.payload);
2036
+ } catch (err) {
2037
+ if (Boom.isBoom(err)) {
2038
+ throw err;
2039
+ }
2040
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
2041
+ if (err.code) {
2042
+ error.output.payload.code = err.code;
2043
+ }
2044
+ throw error;
2045
+ }
2046
+ },
2047
+ options: {
2048
+ description: 'Verify IMAP and SMTP settings',
2049
+ notes: 'Checks if can connect and authenticate using provided account info',
2050
+ tags: ['api', 'account'],
2051
+
2052
+ validate: {
2053
+ options: {
2054
+ stripUnknown: false,
2055
+ abortEarly: false,
2056
+ convert: true
2057
+ },
2058
+ failAction,
2059
+
2060
+ payload: Joi.object({
2061
+ mailboxes: Joi.boolean().example(false).description('Include mailbox listing in response').default(false),
2062
+ imap: Joi.object(imapSchema).description('IMAP configuration').label('IMAP'),
2063
+ smtp: Joi.object(smtpSchema).allow(false).description('SMTP configuration').label('SMTP')
2064
+ }).label('VerifyAccount')
2065
+ },
2066
+ response: {
2067
+ schema: Joi.object({
2068
+ imap: Joi.object({
2069
+ success: Joi.boolean().example(true).description('Was IMAP account verified').label('VerifyImapSuccess'),
2070
+ error: Joi.string()
2071
+ .example('Something went wrong')
2072
+ .description('Error messages for IMAP verification. Only present if success=false')
2073
+ .label('VerifyImapError'),
2074
+ code: Joi.string()
2075
+ .example('ERR_SSL_WRONG_VERSION_NUMBER')
2076
+ .description('Error code. Only present if success=false')
2077
+ .label('VerifyImapCode')
2078
+ }),
2079
+ smtp: Joi.object({
2080
+ success: Joi.boolean().example(true).description('Was SMTP account verified').label('VerifySmtpSuccess'),
2081
+ error: Joi.string()
2082
+ .example('Something went wrong')
2083
+ .description('Error messages for SMTP verification. Only present if success=false')
2084
+ .label('VerifySmtpError'),
2085
+ code: Joi.string()
2086
+ .example('ERR_SSL_WRONG_VERSION_NUMBER')
2087
+ .description('Error code. Only present if success=false')
2088
+ .label('VerifySmtpCode')
2089
+ }),
2090
+ mailboxes: shortMailboxesSchema
2091
+ }).label('VerifyAccountReponse'),
2092
+ failAction: 'log'
2093
+ }
2094
+ }
2095
+ });
2096
+
2097
+ server.route({
2098
+ method: 'GET',
2099
+ path: '/metrics',
2100
+ async handler(request, h) {
2101
+ const renderedMetrics = await call({ cmd: 'metrics' });
2102
+ const response = h.response('success');
2103
+ response.type('text/plain');
2104
+ return renderedMetrics;
2105
+ }
2106
+ });
2107
+
2108
+ server.route({
2109
+ method: '*',
2110
+ path: '/{any*}',
2111
+ async handler() {
2112
+ throw Boom.notFound('Requested page not found'); // 404
2113
+ }
2114
+ });
2115
+
2116
+ await server.start();
2117
+ };
2118
+
2119
+ function getLogs(account) {
2120
+ let logKey = `iam:${account}:g`;
2121
+ let passThrough = new PassThrough();
2122
+
2123
+ redis
2124
+ .lrangeBuffer(logKey, 0, -1)
2125
+ .then(rows => {
2126
+ if (!rows || !Array.isArray(rows) || !rows.length) {
2127
+ return passThrough.end(`No logs found for ${account}\n`);
2128
+ }
2129
+ let processNext = () => {
2130
+ if (!rows.length) {
2131
+ return passThrough.end();
2132
+ }
2133
+
2134
+ let row = rows.shift();
2135
+ let entry;
2136
+ try {
2137
+ entry = msgpack.decode(row);
2138
+ } catch (err) {
2139
+ entry = { error: err.stack };
2140
+ }
2141
+
2142
+ if (entry) {
2143
+ if (!passThrough.write(JSON.stringify(entry) + '\n')) {
2144
+ return passThrough.once('drain', processNext);
2145
+ }
2146
+ }
2147
+
2148
+ setImmediate(processNext);
2149
+ };
2150
+
2151
+ processNext();
2152
+ })
2153
+ .catch(err => {
2154
+ passThrough.end(`\nFailed to process logs\n${err.stack}\n`);
2155
+ });
2156
+
2157
+ return passThrough;
2158
+ }
2159
+
2160
+ async function verifyAccountInfo(accountData) {
2161
+ let response = {};
2162
+
2163
+ if (accountData.imap) {
2164
+ try {
2165
+ let imapClient = new ImapFlow(
2166
+ Object.assign(
2167
+ {
2168
+ verifyOnly: true,
2169
+ includeMailboxes: accountData.mailboxes
2170
+ },
2171
+ accountData.imap
2172
+ )
2173
+ );
2174
+
2175
+ let mailboxes = await new Promise((resolve, reject) => {
2176
+ imapClient.on('error', err => {
2177
+ reject(err);
2178
+ });
2179
+ imapClient
2180
+ .connect()
2181
+ .then(() => resolve(imapClient._mailboxList))
2182
+ .catch(reject);
2183
+ });
2184
+
2185
+ response.imap = {
2186
+ success: !!imapClient.authenticated
2187
+ };
2188
+
2189
+ if (accountData.mailboxes && mailboxes && mailboxes.length) {
2190
+ // format mailbox listing
2191
+ let mailboxList = [];
2192
+ for (let entry of mailboxes) {
2193
+ let mailbox = {};
2194
+ Object.keys(entry).forEach(key => {
2195
+ if (['path', 'specialUse', 'name', 'listed', 'subscribed', 'delimiter'].includes(key)) {
2196
+ mailbox[key] = entry[key];
2197
+ }
2198
+ });
2199
+ if (mailbox.delimiter && mailbox.path.indexOf(mailbox.delimiter) >= 0) {
2200
+ mailbox.parentPath = mailbox.path.substr(0, mailbox.path.lastIndexOf(mailbox.delimiter));
2201
+ }
2202
+ mailboxList.push(mailbox);
2203
+ }
2204
+ response.mailboxes = mailboxList;
2205
+ }
2206
+ } catch (err) {
2207
+ response.imap = {
2208
+ success: false,
2209
+ error: err.message,
2210
+ code: err.code,
2211
+ statusCode: err.statusCode
2212
+ };
2213
+ }
2214
+ }
2215
+
2216
+ if (accountData.smtp) {
2217
+ try {
2218
+ let smtpClient = nodemailer.createTransport(Object.assign({}, accountData.smtp));
2219
+ response.smtp = {
2220
+ success: await smtpClient.verify()
2221
+ };
2222
+ } catch (err) {
2223
+ response.smtp = {
2224
+ success: false,
2225
+ error: err.message,
2226
+ code: err.code,
2227
+ statusCode: err.statusCode
2228
+ };
2229
+ }
2230
+ }
2231
+
2232
+ return response;
2233
+ }
2234
+
2235
+ async function getStats(seconds) {
2236
+ const structuredMetrics = await call({ cmd: 'structuredMetrics' });
2237
+
2238
+ let counters = await getCounterValues(redis, seconds);
2239
+
2240
+ let stats = Object.assign(
2241
+ {
2242
+ version: packageData.version,
2243
+ license: packageData.license,
2244
+ accounts: await redis.scard('ia:accounts'),
2245
+ counters
2246
+ },
2247
+ structuredMetrics
2248
+ );
2249
+
2250
+ return stats;
2251
+ }
2252
+
2253
+ init()
2254
+ .then(() => {
2255
+ logger.debug({
2256
+ msg: 'Started API server thread',
2257
+ port: API_PORT,
2258
+ host: API_HOST,
2259
+ maxSize: MAX_ATTACHMENT_SIZE,
2260
+ version: packageData.version
2261
+ });
2262
+ })
2263
+ .catch(err => {
2264
+ logger.error(err);
2265
+ setImmediate(() => process.exit(3));
2266
+ });