diff --git a/.env.example b/.env.example index 80b6102..66f79dc 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,11 @@ NUXT_MINIO_SECRET_KEY=your-minio-secret-key # NocoDB Configuration (existing) NUXT_NOCODB_URL=your-nocodb-url NUXT_NOCODB_TOKEN=your-nocodb-token + +# Email Configuration +NUXT_EMAIL_ENCRYPTION_KEY=your-32-character-encryption-key +NUXT_EMAIL_IMAP_HOST=mail.portnimara.com +NUXT_EMAIL_IMAP_PORT=993 +NUXT_EMAIL_SMTP_HOST=mail.portnimara.com +NUXT_EMAIL_SMTP_PORT=587 +NUXT_EMAIL_LOGO_URL=https://portnimara.com/logo.png diff --git a/components/EmailCommunication.vue b/components/EmailCommunication.vue new file mode 100644 index 0000000..b45396a --- /dev/null +++ b/components/EmailCommunication.vue @@ -0,0 +1,99 @@ + + + diff --git a/components/EmailComposer.vue b/components/EmailComposer.vue new file mode 100644 index 0000000..e58fb62 --- /dev/null +++ b/components/EmailComposer.vue @@ -0,0 +1,301 @@ + + + diff --git a/components/EmailCredentialsSetup.vue b/components/EmailCredentialsSetup.vue new file mode 100644 index 0000000..7c008e5 --- /dev/null +++ b/components/EmailCredentialsSetup.vue @@ -0,0 +1,159 @@ + + + diff --git a/components/EmailThreadView.vue b/components/EmailThreadView.vue new file mode 100644 index 0000000..4d6fff8 --- /dev/null +++ b/components/EmailThreadView.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/components/InterestDetailsModal.vue b/components/InterestDetailsModal.vue index 37b45e5..7c90c16 100644 --- a/components/InterestDetailsModal.vue +++ b/components/InterestDetailsModal.vue @@ -625,6 +625,12 @@ + + + @@ -635,6 +641,7 @@ import { ref, computed, watch, onMounted } from "vue"; import type { Interest, Berth } from "@/utils/types"; import PhoneInput from "./PhoneInput.vue"; +import EmailCommunication from "./EmailCommunication.vue"; import { InterestSalesProcessLevelFlow, InterestLeadCategoryFlow, diff --git a/package-lock.json b/package-lock.json index c41503c..99c9302 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,22 @@ "dependencies": { "@vite-pwa/nuxt": "^0.10.6", "formidable": "^3.5.4", + "imap": "^0.8.19", + "mailparser": "^3.7.3", "mime-types": "^3.0.1", "minio": "^8.0.5", + "nodemailer": "^7.0.3", "nuxt": "^3.15.4", "nuxt-directus": "^5.7.0", "v-phone-input": "^4.4.2", "vue": "latest", "vue-router": "latest", "vuetify-nuxt-module": "^0.18.3" + }, + "devDependencies": { + "@types/imap": "^0.8.42", + "@types/mailparser": "^3.4.6", + "@types/nodemailer": "^6.4.17" } }, "node_modules/@ampproject/remapping": { @@ -3622,6 +3630,19 @@ "win32" ] }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@sindresorhus/merge-streams": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", @@ -3679,6 +3700,27 @@ "@types/node": "*" } }, + "node_modules/@types/imap": { + "version": "0.8.42", + "resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz", + "integrity": "sha512-FusePG9Cp2GYN6OLow9xBCkjznFkAR7WCz0Fm+j1p/ER6C8V8P71DtjpSmwrZsS7zekCeqdTPHEk9N5OgPwcsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mailparser": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz", + "integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "iconv-lite": "^0.6.3" + } + }, "node_modules/@types/node": { "version": "22.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz", @@ -3688,6 +3730,16 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.17", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", + "integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-path": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz", @@ -5975,6 +6027,15 @@ "node": ">= 0.8" } }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.0.tgz", @@ -7065,6 +7126,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", @@ -7083,6 +7153,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -7137,6 +7242,18 @@ "node": ">=14.18.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -7178,6 +7295,42 @@ "integrity": "sha512-K6acvFaelNxx8wc2VjbIzXKDVB0Khs0QT35U6NkGfTdCmjLNcO2945m7RFNR9/RPVFm48hq7QPzK8uGH18HCGw==", "license": "MIT" }, + "node_modules/imap": { + "version": "0.8.19", + "resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz", + "integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==", + "dependencies": { + "readable-stream": "1.1.x", + "utf7": ">=1.0.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/imap/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/imap/node_modules/readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/imap/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, "node_modules/importx": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/importx/-/importx-0.4.4.tgz", @@ -8538,6 +8691,15 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -8547,6 +8709,30 @@ "node": ">=6" } }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.6.tgz", + "integrity": "sha512-j9mBC7eiqi6fgBPAGvKCXJKJSIASanYF4EeA4iBzSG0HxQxmXnR3KbyWqTn4CwsKSebqCv2f5XZfAO6sKzgvwA==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -8559,6 +8745,15 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/listhen": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/listhen/-/listhen-1.9.0.tgz", @@ -8703,6 +8898,35 @@ "source-map-js": "^1.2.0" } }, + "node_modules/mailparser": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.3.tgz", + "integrity": "sha512-0RM14cZF0gO1y2Q/82hhWranispZOUSYHwvQ21h12x90NwD6+D5q59S5nOLqCtCdYitHN58LJXWEHa4RWm7BYA==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "9.0.5", + "iconv-lite": "0.6.3", + "libmime": "5.3.6", + "linkify-it": "5.0.0", + "mailsplit": "5.4.3", + "nodemailer": "7.0.3", + "punycode.js": "2.3.1", + "tlds": "1.259.0" + } + }, + "node_modules/mailsplit": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.3.tgz", + "integrity": "sha512-PFV0BBh4Tv7Omui5FtXXVtN4ExAxIi8Yvmb9JgBz+J6Hnnrv/YYXLlKKudLhXwd3/qWEATOslRsnzVCWDeCnmQ==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.6", + "libqp": "2.1.1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -9251,6 +9475,15 @@ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz", + "integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", @@ -9697,6 +9930,19 @@ "node": ">=14.13.0" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -9770,6 +10016,15 @@ "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==", "license": "MIT" }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -10406,6 +10661,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/query-string": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", @@ -11007,6 +11271,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -11019,6 +11289,18 @@ "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "license": "MIT" }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.0.tgz", @@ -11953,6 +12235,15 @@ "node": ">=12.0.0" } }, + "node_modules/tlds": { + "version": "1.259.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz", + "integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -12531,6 +12822,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/ufo": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", @@ -13142,6 +13439,23 @@ "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==", "license": "MIT" }, + "node_modules/utf7": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz", + "integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==", + "dependencies": { + "semver": "~5.3.0" + } + }, + "node_modules/utf7/node_modules/semver": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", diff --git a/package.json b/package.json index c434444..f3ced6f 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,21 @@ "dependencies": { "@vite-pwa/nuxt": "^0.10.6", "formidable": "^3.5.4", + "imap": "^0.8.19", + "mailparser": "^3.7.3", "mime-types": "^3.0.1", "minio": "^8.0.5", + "nodemailer": "^7.0.3", "nuxt": "^3.15.4", "nuxt-directus": "^5.7.0", "v-phone-input": "^4.4.2", "vue": "latest", "vue-router": "latest", "vuetify-nuxt-module": "^0.18.3" + }, + "devDependencies": { + "@types/imap": "^0.8.42", + "@types/mailparser": "^3.4.6", + "@types/nodemailer": "^6.4.17" } } diff --git a/server/api/email/fetch-thread.ts b/server/api/email/fetch-thread.ts new file mode 100644 index 0000000..9f763f0 --- /dev/null +++ b/server/api/email/fetch-thread.ts @@ -0,0 +1,266 @@ +import Imap from 'imap'; +import { simpleParser } from 'mailparser'; +import { getCredentialsFromSession, decryptCredentials } from '~/server/utils/encryption'; +import { listFiles, getFileStats } from '~/server/utils/minio'; + +interface EmailMessage { + id: string; + from: string; + to: string | string[]; + subject: string; + body: string; + html?: string; + timestamp: string; + direction: 'sent' | 'received'; + threadId?: string; +} + +export default defineEventHandler(async (event) => { + const xTagHeader = getRequestHeader(event, "x-tag"); + + if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) { + throw createError({ statusCode: 401, statusMessage: "unauthenticated" }); + } + + try { + const body = await readBody(event); + const { clientEmail, interestId, sessionId, limit = 50 } = body; + + if (!clientEmail || !sessionId) { + throw createError({ + statusCode: 400, + statusMessage: "Client email and sessionId are required" + }); + } + + // Get encrypted credentials from session + const encryptedCredentials = getCredentialsFromSession(sessionId); + if (!encryptedCredentials) { + throw createError({ + statusCode: 401, + statusMessage: "Email credentials not found. Please reconnect." + }); + } + + // Decrypt credentials + const { email: userEmail, password } = decryptCredentials(encryptedCredentials); + + // First, get emails from MinIO cache if available + const cachedEmails: EmailMessage[] = []; + if (interestId) { + try { + const files = await listFiles(`client-emails/interest-${interestId}/`, true) as any[]; + for (const file of files) { + if (file.name.endsWith('.json') && !file.isFolder) { + try { + const response = await fetch(`${process.env.NUXT_MINIO_ENDPOINT || 'http://localhost:9000'}/${useRuntimeConfig().minio.bucketName}/${file.name}`); + const emailData = await response.json(); + cachedEmails.push(emailData); + } catch (err) { + console.error('Failed to read cached email:', err); + } + } + } + } catch (err) { + console.error('Failed to list cached emails:', err); + } + } + + // Configure IMAP + const imapConfig = { + user: userEmail, + password: password, + host: process.env.NUXT_EMAIL_IMAP_HOST || 'mail.portnimara.com', + port: parseInt(process.env.NUXT_EMAIL_IMAP_PORT || '993'), + tls: true, + tlsOptions: { + rejectUnauthorized: false + } + }; + + // Fetch emails from IMAP + const imapEmails: EmailMessage[] = await new Promise((resolve, reject) => { + const emails: EmailMessage[] = []; + const imap = new Imap(imapConfig); + + imap.once('ready', () => { + // Search for emails to/from the client + imap.openBox('INBOX', true, (err, box) => { + if (err) { + reject(err); + return; + } + + const searchCriteria = [ + 'OR', + ['FROM', clientEmail], + ['TO', clientEmail] + ]; + + imap.search(searchCriteria, (err, results) => { + if (err) { + reject(err); + return; + } + + if (!results || results.length === 0) { + imap.end(); + resolve(emails); + return; + } + + // Limit results + const messagesToFetch = results.slice(-limit); + + const fetch = imap.fetch(messagesToFetch, { + bodies: '', + struct: true, + envelope: true + }); + + fetch.on('message', (msg, seqno) => { + msg.on('body', (stream, info) => { + simpleParser(stream as any, async (err: any, parsed: any) => { + if (err) { + console.error('Parse error:', err); + return; + } + + const email: EmailMessage = { + id: parsed.messageId || `${Date.now()}-${seqno}`, + from: parsed.from?.text || '', + to: Array.isArray(parsed.to) + ? parsed.to.map((addr: any) => addr.text).join(', ') + : parsed.to?.text || '', + subject: parsed.subject || '', + body: parsed.text || '', + html: parsed.html || undefined, + timestamp: parsed.date?.toISOString() || new Date().toISOString(), + direction: parsed.from?.text.includes(userEmail) ? 'sent' : 'received' + }; + + // Extract thread ID from headers if available + if (parsed.headers.has('in-reply-to')) { + email.threadId = parsed.headers.get('in-reply-to') as string; + } + + emails.push(email); + }); + }); + }); + + fetch.once('error', (err) => { + console.error('Fetch error:', err); + reject(err); + }); + + fetch.once('end', () => { + imap.end(); + }); + }); + }); + + // Also check Sent folder + imap.openBox('[Gmail]/Sent Mail', true, (err, box) => { + if (err) { + // Try common sent folder names + ['Sent', 'Sent Items', 'Sent Messages'].forEach(folderName => { + imap.openBox(folderName, true, (err, box) => { + if (!err) { + // Search in sent folder + imap.search([['TO', clientEmail]], (err, results) => { + if (!err && results && results.length > 0) { + // Process sent emails similarly + } + }); + } + }); + }); + } + }); + }); + + imap.once('error', (err: any) => { + reject(err); + }); + + imap.once('end', () => { + resolve(emails); + }); + + imap.connect(); + }); + + // Combine cached and IMAP emails, remove duplicates + const allEmails = [...cachedEmails, ...imapEmails]; + const uniqueEmails = Array.from( + new Map(allEmails.map(email => [email.id, email])).values() + ); + + // Sort by timestamp + uniqueEmails.sort((a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + + // Group into threads + const threads = groupIntoThreads(uniqueEmails); + + return { + success: true, + emails: uniqueEmails, + threads: threads + }; + } catch (error) { + console.error('Failed to fetch email thread:', error); + if (error instanceof Error) { + throw createError({ + statusCode: 500, + statusMessage: `Failed to fetch emails: ${error.message}` + }); + } else { + throw createError({ + statusCode: 500, + statusMessage: "An unexpected error occurred", + }); + } + } +}); + +// Group emails into threads based on subject and references +function groupIntoThreads(emails: EmailMessage[]): any[] { + const threads = new Map(); + + emails.forEach(email => { + // Normalize subject by removing Re:, Fwd:, etc. + const normalizedSubject = email.subject + .replace(/^(Re:|Fwd:|Fw:)\s*/gi, '') + .trim(); + + // Find existing thread or create new one + let threadFound = false; + for (const [threadId, threadEmails] of threads.entries()) { + const threadSubject = threadEmails[0].subject + .replace(/^(Re:|Fwd:|Fw:)\s*/gi, '') + .trim(); + + if (threadSubject === normalizedSubject) { + threadEmails.push(email); + threadFound = true; + break; + } + } + + if (!threadFound) { + threads.set(email.id, [email]); + } + }); + + // Convert to array format + return Array.from(threads.entries()).map(([threadId, emails]) => ({ + id: threadId, + subject: emails[0].subject, + emailCount: emails.length, + latestTimestamp: emails[emails.length - 1].timestamp, + emails: emails + })); +} diff --git a/server/api/email/send.ts b/server/api/email/send.ts new file mode 100644 index 0000000..48485a9 --- /dev/null +++ b/server/api/email/send.ts @@ -0,0 +1,144 @@ +import nodemailer from 'nodemailer'; +import { getCredentialsFromSession, decryptCredentials } from '~/server/utils/encryption'; +import { uploadFile } from '~/server/utils/minio'; + +export default defineEventHandler(async (event) => { + const xTagHeader = getRequestHeader(event, "x-tag"); + + if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) { + throw createError({ statusCode: 401, statusMessage: "unauthenticated" }); + } + + try { + const body = await readBody(event); + const { + to, + subject, + body: emailBody, + interestId, + sessionId, + includeSignature = true, + signatureConfig + } = body; + + if (!to || !subject || !emailBody || !sessionId) { + throw createError({ + statusCode: 400, + statusMessage: "To, subject, body, and sessionId are required" + }); + } + + // Get encrypted credentials from session + const encryptedCredentials = getCredentialsFromSession(sessionId); + if (!encryptedCredentials) { + throw createError({ + statusCode: 401, + statusMessage: "Email credentials not found. Please reconnect." + }); + } + + // Decrypt credentials + const { email, password } = decryptCredentials(encryptedCredentials); + + // Get user info for signature + const defaultName = email.split('@')[0].replace('.', ' ').replace(/\b\w/g, l => l.toUpperCase()); + + // Build email signature with customizable fields + const sig = signatureConfig || {}; + const contactLines = sig.contactInfo ? sig.contactInfo.split('\n').filter((line: string) => line.trim()).join('
') : ''; + const signature = includeSignature ? ` +
+
${sig.name || defaultName}
+
${sig.title || 'Sales & Marketing Director'}
+
+
${sig.company || 'Port Nimara'}
+
+ ${contactLines ? contactLines + '
' : ''} + ${sig.email || email} +

+ Port Nimara +
+
+ The information in this message is confidential and may be privileged.
+ It is intended for the addressee alone.
+ If you are not the intended recipient it is prohibited to disclose, use or copy this information.
+ Please contact the Sender immediately should this message have been transmitted incorrectly. +
+
` : ''; + + // Convert plain text body to HTML with line breaks + const htmlBody = emailBody.replace(/\n/g, '
') + signature; + + // Configure SMTP transport + const transporter = nodemailer.createTransport({ + host: process.env.NUXT_EMAIL_SMTP_HOST || 'mail.portnimara.com', + port: parseInt(process.env.NUXT_EMAIL_SMTP_PORT || '587'), + secure: false, // false for STARTTLS + auth: { + user: email, + pass: password + }, + tls: { + rejectUnauthorized: false // Allow self-signed certificates + } + }); + + // Send email + const fromName = sig.name || defaultName; + const info = await transporter.sendMail({ + from: `"${fromName}" <${email}>`, + to: to, + subject: subject, + text: emailBody, // Plain text version + html: htmlBody // HTML version with signature + }); + + // Store email in MinIO for thread history + if (interestId) { + try { + const emailData = { + id: info.messageId, + from: email, + to: to, + subject: subject, + body: emailBody, + html: htmlBody, + timestamp: new Date().toISOString(), + direction: 'sent', + interestId: interestId + }; + + const objectName = `client-emails/interest-${interestId}/${Date.now()}-sent.json`; + const buffer = Buffer.from(JSON.stringify(emailData, null, 2)); + + await uploadFile( + objectName, + buffer, + 'application/json' + ); + } catch (storageError) { + console.error('Failed to store email in MinIO:', storageError); + // Continue even if storage fails + } + } + + return { + success: true, + message: "Email sent successfully", + messageId: info.messageId + }; + } catch (error) { + console.error('Failed to send email:', error); + if (error instanceof Error) { + throw createError({ + statusCode: 500, + statusMessage: `Failed to send email: ${error.message}` + }); + } else { + throw createError({ + statusCode: 500, + statusMessage: "An unexpected error occurred", + }); + } + } +}); diff --git a/server/api/email/test-connection.ts b/server/api/email/test-connection.ts new file mode 100644 index 0000000..8dc17e9 --- /dev/null +++ b/server/api/email/test-connection.ts @@ -0,0 +1,106 @@ +import nodemailer from 'nodemailer'; +import Imap from 'imap'; +import { encryptCredentials, storeCredentialsInSession } from '~/server/utils/encryption'; + +export default defineEventHandler(async (event) => { + const xTagHeader = getRequestHeader(event, "x-tag"); + + if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) { + throw createError({ statusCode: 401, statusMessage: "unauthenticated" }); + } + + try { + const body = await readBody(event); + const { email, password, imapHost, smtpHost, sessionId } = body; + + if (!email || !password || !sessionId) { + throw createError({ + statusCode: 400, + statusMessage: "Email, password, and sessionId are required" + }); + } + + // Use provided hosts or defaults from environment + const imapHostToUse = imapHost || process.env.NUXT_EMAIL_IMAP_HOST || 'mail.portnimara.com'; + const smtpHostToUse = smtpHost || process.env.NUXT_EMAIL_SMTP_HOST || 'mail.portnimara.com'; + const imapPort = parseInt(process.env.NUXT_EMAIL_IMAP_PORT || '993'); + const smtpPort = parseInt(process.env.NUXT_EMAIL_SMTP_PORT || '587'); + + // Test SMTP connection + const transporter = nodemailer.createTransport({ + host: smtpHostToUse, + port: smtpPort, + secure: false, // false for STARTTLS + auth: { + user: email, + pass: password + }, + tls: { + rejectUnauthorized: false // Allow self-signed certificates + } + }); + + await transporter.verify(); + + // Test IMAP connection + const imapConfig = { + user: email, + password: password, + host: imapHostToUse, + port: imapPort, + tls: true, + tlsOptions: { + rejectUnauthorized: false // Allow self-signed certificates + } + }; + + const testImapConnection = () => { + return new Promise((resolve, reject) => { + const imap = new Imap(imapConfig); + + imap.once('ready', () => { + imap.end(); + resolve(true); + }); + + imap.once('error', (err: Error) => { + reject(err); + }); + + imap.connect(); + }); + }; + + await testImapConnection(); + + // If both connections successful, encrypt and store credentials + const encryptedCredentials = encryptCredentials(email, password); + storeCredentialsInSession(sessionId, encryptedCredentials); + + return { + success: true, + message: "Email connection tested successfully", + email: email + }; + } catch (error) { + console.error('Email connection test failed:', error); + if (error instanceof Error) { + // Check for common authentication errors + if (error.message.includes('Authentication') || error.message.includes('AUTHENTICATIONFAILED')) { + throw createError({ + statusCode: 401, + statusMessage: "Invalid email or password" + }); + } + throw createError({ + statusCode: 500, + statusMessage: `Connection failed: ${error.message}` + }); + } else { + throw createError({ + statusCode: 500, + statusMessage: "An unexpected error occurred", + }); + } + } +}); diff --git a/server/utils/encryption.ts b/server/utils/encryption.ts new file mode 100644 index 0000000..bb440e9 --- /dev/null +++ b/server/utils/encryption.ts @@ -0,0 +1,117 @@ +import crypto from 'crypto'; + +const algorithm = 'aes-256-gcm'; +const saltLength = 64; +const tagLength = 16; +const ivLength = 16; +const iterations = 100000; +const keyLength = 32; + +function getKey(): Buffer { + const key = process.env.NUXT_EMAIL_ENCRYPTION_KEY; + if (!key || key.length < 32) { + throw new Error('NUXT_EMAIL_ENCRYPTION_KEY must be at least 32 characters long'); + } + // Ensure key is exactly 32 bytes + return Buffer.from(key.substring(0, 32).padEnd(32, '0')); +} + +export function encryptCredentials(email: string, password: string): string { + try { + const key = getKey(); + const iv = crypto.randomBytes(ivLength); + const salt = crypto.randomBytes(saltLength); + + const derivedKey = crypto.pbkdf2Sync(key, salt, iterations, keyLength, 'sha256'); + const cipher = crypto.createCipheriv(algorithm, derivedKey, iv); + + const data = JSON.stringify({ email, password }); + const encrypted = Buffer.concat([ + cipher.update(data, 'utf8'), + cipher.final() + ]); + + const tag = cipher.getAuthTag(); + + // Combine salt, iv, tag, and encrypted data + const combined = Buffer.concat([salt, iv, tag, encrypted]); + + return combined.toString('base64'); + } catch (error) { + throw new Error('Failed to encrypt credentials'); + } +} + +export function decryptCredentials(encryptedData: string): { email: string; password: string } { + try { + const key = getKey(); + const combined = Buffer.from(encryptedData, 'base64'); + + // Extract components + const salt = combined.slice(0, saltLength); + const iv = combined.slice(saltLength, saltLength + ivLength); + const tag = combined.slice(saltLength + ivLength, saltLength + ivLength + tagLength); + const encrypted = combined.slice(saltLength + ivLength + tagLength); + + const derivedKey = crypto.pbkdf2Sync(key, salt, iterations, keyLength, 'sha256'); + const decipher = crypto.createDecipheriv(algorithm, derivedKey, iv); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final() + ]); + + return JSON.parse(decrypted.toString('utf8')); + } catch (error) { + throw new Error('Failed to decrypt credentials'); + } +} + +// In-memory session storage for credentials (cleared on server restart) +const credentialCache = new Map(); +const CACHE_TTL = 30 * 60 * 1000; // 30 minutes + +export function storeCredentialsInSession(sessionId: string, encryptedCredentials: string): void { + credentialCache.set(sessionId, { + credentials: encryptedCredentials, + timestamp: Date.now() + }); + + // Clean up expired sessions + cleanupExpiredSessions(); +} + +export function getCredentialsFromSession(sessionId: string): string | null { + const session = credentialCache.get(sessionId); + + if (!session) { + return null; + } + + // Check if session is expired + if (Date.now() - session.timestamp > CACHE_TTL) { + credentialCache.delete(sessionId); + return null; + } + + // Update timestamp on access + session.timestamp = Date.now(); + return session.credentials; +} + +export function clearCredentialsFromSession(sessionId: string): void { + credentialCache.delete(sessionId); +} + +function cleanupExpiredSessions(): void { + const now = Date.now(); + for (const [sessionId, session] of credentialCache.entries()) { + if (now - session.timestamp > CACHE_TTL) { + credentialCache.delete(sessionId); + } + } +} + +// Cleanup expired sessions every 5 minutes +setInterval(cleanupExpiredSessions, 5 * 60 * 1000);