diff --git a/package-lock.json b/package-lock.json index 4b6370c..549251b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,9 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dotenv": "^17.3.1", + "imapflow": "^1.2.18", "lucide-react": "^0.577.0", + "mailparser": "^3.9.6", "next": "16.1.6", "next-themes": "^0.4.6", "pdf2pic": "^3.2.0", @@ -41,6 +43,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/mailparser": "^3.4.6", "@types/node": "^25.4.0", "@types/pg": "^8.18.0", "@types/react": "^19", @@ -2991,6 +2994,12 @@ "node": ">=8.0.0" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@prisma/adapter-pg": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.2.tgz", @@ -3639,6 +3648,19 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, + "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": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", @@ -4809,6 +4831,30 @@ "dev": true, "license": "MIT" }, + "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/mailparser/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@types/node": { "version": "25.4.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz", @@ -5435,6 +5481,17 @@ "node": ">= 20" } }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", + "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -5819,6 +5876,15 @@ "node": ">= 0.4" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -6799,6 +6865,61 @@ "node": ">=0.10.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -6889,6 +7010,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.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -6903,6 +7033,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -8443,6 +8585,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/headers-polyfill": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", @@ -8475,6 +8626,41 @@ "node": ">=16.9.0" } }, + "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.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -8548,6 +8734,23 @@ "node": ">= 4" } }, + "node_modules/imapflow": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.18.tgz", + "integrity": "sha512-zxYvcG9ckj/UcTRs+ZDT+wJzW8DqkjgWZwc1z4Q28R/4C/1YvJieVETOuR/9ztCXcycURC50PJShMimITvz5wQ==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1", + "nodemailer": "8.0.4", + "pino": "10.3.1", + "socks": "2.8.7" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9387,6 +9590,15 @@ "node": ">=0.10" } }, + "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/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -9401,6 +9613,42 @@ "node": ">= 0.8.0" } }, + "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.7", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", + "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/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/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -9677,6 +9925,15 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "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/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -9808,6 +10065,24 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mailparser": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.6.tgz", + "integrity": "sha512-EJYTDWMrOS1kddK1mTsRkrx2Ngh2nYsg54SRMWVVWGVEGbHH4tod8tqqU9hIRPgGQVboSjFubDn9cboSitbM3Q==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "9.0.5", + "iconv-lite": "0.7.2", + "libmime": "5.3.7", + "linkify-it": "5.0.0", + "nodemailer": "8.0.4", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -10248,6 +10523,15 @@ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", + "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", @@ -10435,6 +10719,15 @@ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "license": "MIT" }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -10648,6 +10941,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -10717,6 +11023,15 @@ "url": "https://www.paypal.me/yakovmeister" } }, + "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", @@ -10839,6 +11154,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -11032,6 +11384,22 @@ } } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -11106,6 +11474,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/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -11157,6 +11534,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -11301,6 +11684,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -11597,6 +11989,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -11609,6 +12010,18 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "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": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -11987,6 +12400,39 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -12399,6 +12845,18 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -12462,6 +12920,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/tldts": { "version": "7.0.25", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", @@ -12735,6 +13202,12 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "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/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index 79e4b52..1a2c4be 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dotenv": "^17.3.1", + "imapflow": "^1.2.18", "lucide-react": "^0.577.0", + "mailparser": "^3.9.6", "next": "16.1.6", "next-themes": "^0.4.6", "pdf2pic": "^3.2.0", @@ -45,6 +47,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/mailparser": "^3.4.6", "@types/node": "^25.4.0", "@types/pg": "^8.18.0", "@types/react": "^19", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 27c4e99..6ddcdb6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -85,4 +85,14 @@ model AppSettings { aiProvider String @default("gateway") aiModel String @default("") + + emailImapHost String @default("imap.dreamhost.com") + emailImapPort Int @default(993) + emailImapUser String @default("echo-ocr@stillwell.cloud") + emailImapPass String @default("") + emailImapTls Boolean @default(true) + emailFolder String @default("INBOX") + emailWatching Boolean @default(false) + emailProcessed String @default("mark_read") + emailProcessedFolder String @default("Processed") } diff --git a/src/app/api/email-watch/route.ts b/src/app/api/email-watch/route.ts new file mode 100644 index 0000000..2080c7d --- /dev/null +++ b/src/app/api/email-watch/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { + startEmailWatching, + stopEmailWatching, + isEmailWatching, +} from "@/lib/email-watcher"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const action = body.action as string | undefined; + + if (!action || !["start", "stop"].includes(action)) { + return NextResponse.json( + { error: "Invalid action. Use 'start' or 'stop'" }, + { status: 400 } + ); + } + + if (action === "start") { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + }); + + if (!settings) { + return NextResponse.json( + { error: "Settings not configured" }, + { status: 400 } + ); + } + + if (!settings.emailImapHost || !settings.emailImapUser || !settings.emailImapPass) { + return NextResponse.json( + { error: "IMAP host, username, and password are required" }, + { status: 400 } + ); + } + + await startEmailWatching({ + host: settings.emailImapHost, + port: settings.emailImapPort, + user: settings.emailImapUser, + pass: settings.emailImapPass, + tls: settings.emailImapTls, + folder: settings.emailFolder, + processedAction: settings.emailProcessed, + processedFolder: settings.emailProcessedFolder, + }); + } else { + await stopEmailWatching(); + } + + return NextResponse.json({ emailWatching: isEmailWatching() }); + } catch (error) { + console.error("[email-watch POST]", error); + const message = + error instanceof Error ? error.message : "Failed to update email watch"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/email-watch/test/route.ts b/src/app/api/email-watch/test/route.ts new file mode 100644 index 0000000..1ef02d2 --- /dev/null +++ b/src/app/api/email-watch/test/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { testEmailConnection } from "@/lib/email-watcher"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + + const host = String(body.host || ""); + const port = parseInt(String(body.port)) || 993; + const user = String(body.user || ""); + const pass = String(body.pass || ""); + const tls = body.tls !== false; + + if (!host || !user || !pass) { + return NextResponse.json( + { error: "Host, username, and password are required" }, + { status: 400 } + ); + } + + const result = await testEmailConnection({ host, port, user, pass, tls, folder: "INBOX" }); + + if (result.ok) { + return NextResponse.json({ + ok: true, + folders: result.folders, + message: `Connected to ${host} — ${result.folders?.length || 0} folder(s) found`, + }); + } else { + return NextResponse.json( + { ok: false, error: result.error }, + { status: 400 } + ); + } + } catch (error) { + console.error("[email-watch/test POST]", error); + const message = + error instanceof Error ? error.message : "Connection test failed"; + return NextResponse.json({ ok: false, error: message }, { status: 500 }); + } +} diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 80922db..f2d64d8 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -38,6 +38,15 @@ export async function PUT(request: NextRequest) { if (body.aiProvider != null) data.aiProvider = String(body.aiProvider); if (body.aiModel != null) data.aiModel = String(body.aiModel); + if (body.emailImapHost != null) data.emailImapHost = String(body.emailImapHost); + if (body.emailImapPort != null) data.emailImapPort = Math.max(1, parseInt(String(body.emailImapPort)) || 993); + if (body.emailImapUser != null) data.emailImapUser = String(body.emailImapUser); + if (body.emailImapPass != null) data.emailImapPass = String(body.emailImapPass); + if (body.emailImapTls != null) data.emailImapTls = Boolean(body.emailImapTls); + if (body.emailFolder != null) data.emailFolder = String(body.emailFolder); + if (body.emailProcessed != null) data.emailProcessed = String(body.emailProcessed); + if (body.emailProcessedFolder != null) data.emailProcessedFolder = String(body.emailProcessedFolder); + const settings = await prisma.appSettings.upsert({ where: { id: "singleton" }, create: { diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 8a113aa..c569d5e 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -18,6 +18,7 @@ import { Moon, Monitor, Bell, + Mail, } from "lucide-react"; import { Header } from "@/components/layout/header"; @@ -60,6 +61,15 @@ type SettingsData = { imageRetentionDays: number; aiProvider: string; aiModel: string; + emailImapHost: string; + emailImapPort: number; + emailImapUser: string; + emailImapPass: string; + emailImapTls: boolean; + emailFolder: string; + emailWatching: boolean; + emailProcessed: string; + emailProcessedFolder: string; }; const NOTIFICATION_STORAGE_KEY = "echo-ocr-notification-prefs"; @@ -99,12 +109,22 @@ export default function SettingsPage() { imageRetentionDays: 180, aiProvider: "gateway", aiModel: "", + emailImapHost: "imap.dreamhost.com", + emailImapPort: 993, + emailImapUser: "echo-ocr@stillwell.cloud", + emailImapPass: "", + emailImapTls: true, + emailFolder: "INBOX", + emailWatching: false, + emailProcessed: "mark_read", + emailProcessedFolder: "Processed", }); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); const [aiTestStatus, setAiTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle"); const [cleanupStatus, setCleanupStatus] = React.useState<{ sourcesEligible: number; imagesEligible: number } | null>(null); const [cleaning, setCleaning] = React.useState(false); + const [emailTestStatus, setEmailTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle"); const [notifPrefs, setNotifPrefs] = React.useState(DEFAULT_NOTIFICATION_PREFS); React.useEffect(() => { @@ -213,6 +233,54 @@ export default function SettingsPage() { } }; + const toggleEmailWatch = async () => { + try { + const action = settings.emailWatching ? "stop" : "start"; + const res = await fetch("/api/email-watch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Failed"); + } + setSettings((s) => ({ ...s, emailWatching: !s.emailWatching })); + toast.success(action === "start" ? "Email monitoring started" : "Email monitoring stopped"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to toggle email monitoring"); + } + }; + + const testEmailConnection = async () => { + setEmailTestStatus("testing"); + try { + const res = await fetch("/api/email-watch/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + host: settings.emailImapHost, + port: settings.emailImapPort, + user: settings.emailImapUser, + pass: settings.emailImapPass, + tls: settings.emailImapTls, + }), + signal: AbortSignal.timeout(15000), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.ok) { + setEmailTestStatus("success"); + toast.success(data.message || "Connected successfully"); + } else { + setEmailTestStatus("error"); + toast.error(data.error || "Connection failed"); + } + } catch { + setEmailTestStatus("error"); + toast.error("Cannot reach email server. Check your configuration."); + } + }; + const runCleanup = async () => { setCleaning(true); try { @@ -471,6 +539,154 @@ export default function SettingsPage() { + + +
+
+ + + Email Monitoring + + Watch an inbox for scanned card attachments +
+ + {emailTestStatus === "success" && } + {emailTestStatus === "error" && } + {emailTestStatus === "testing" && } + {emailTestStatus === "success" + ? "Connected" + : emailTestStatus === "error" + ? "Error" + : emailTestStatus === "testing" + ? "Testing..." + : "Not tested"} + +
+
+ +
+
+ + setSettings((s) => ({ ...s, emailImapHost: e.target.value }))} + placeholder="imap.dreamhost.com" + /> +
+
+ + setSettings((s) => ({ ...s, emailImapPort: parseInt(e.target.value) || 993 }))} + placeholder="993" + /> +
+
+
+
+ + setSettings((s) => ({ ...s, emailImapUser: e.target.value }))} + placeholder="echo-ocr@stillwell.cloud" + /> +
+
+ + setSettings((s) => ({ ...s, emailImapPass: e.target.value }))} + placeholder="••••••••" + /> +
+
+
+
+ + setSettings((s) => ({ ...s, emailFolder: e.target.value }))} + placeholder="INBOX" + /> +
+
+ + +
+
+ {settings.emailProcessed === "move" && ( +
+ + setSettings((s) => ({ ...s, emailProcessedFolder: e.target.value }))} + placeholder="Processed" + /> +
+ )} +
+ + setSettings((s) => ({ ...s, emailImapTls: val }))} + /> +
+
+ + + {settings.emailWatching && ( + + Active + + )} +
+
+
+ diff --git a/src/lib/email-watcher.ts b/src/lib/email-watcher.ts new file mode 100644 index 0000000..cfd7489 --- /dev/null +++ b/src/lib/email-watcher.ts @@ -0,0 +1,265 @@ +import { ImapFlow } from "imapflow"; +import { simpleParser } from "mailparser"; +import { prisma } from "./db"; +import { processFile } from "./ocr"; + +const ALLOWED_CONTENT_TYPES = [ + "application/pdf", + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", +]; + +type EmailConfig = { + host: string; + port: number; + user: string; + pass: string; + tls: boolean; + folder: string; + processedAction: string; + processedFolder: string; +}; + +let client: ImapFlow | null = null; +let watching = false; +let reconnectTimer: ReturnType | null = null; +let currentConfig: EmailConfig | null = null; + +function log(msg: string, ...args: unknown[]) { + console.log(`[email-watcher] ${msg}`, ...args); +} + +function logError(msg: string, ...args: unknown[]) { + console.error(`[email-watcher] ${msg}`, ...args); +} + +async function handleMessage(client: ImapFlow, uid: number, config: EmailConfig) { + const result = await client.fetchOne(String(uid), { source: true }, { uid: true }); + if (!result) return; + const rawSource = (result as unknown as { source?: Buffer }).source; + if (!rawSource) return; + + const parsed = await simpleParser(rawSource); + const subject = parsed.subject || "(no subject)"; + const from = parsed.from?.text || "unknown"; + + const attachments = (parsed.attachments || []).filter((att) => { + const ct = att.contentType?.toLowerCase() || ""; + return ALLOWED_CONTENT_TYPES.includes(ct); + }); + + if (attachments.length === 0) { + log(`Skipping email from ${from} "${subject}" — no valid attachments`); + return; + } + + log(`Processing email from ${from} "${subject}" — ${attachments.length} attachment(s)`); + + for (const att of attachments) { + const fileName = att.filename || `email-attachment-${Date.now()}`; + const buffer = att.content; + const isPdf = att.contentType?.toLowerCase() === "application/pdf"; + + try { + const job = await prisma.processingJob.create({ + data: { + fileName, + filePath: `email/${fileName}`, + status: "queued", + }, + }); + + processFile(job.id, fileName, buffer, isPdf).catch((err) => { + logError(`Processing failed for ${fileName}:`, err); + }); + + log(`Queued: ${fileName} (job ${job.id})`); + } catch (err) { + logError(`Failed to create job for ${fileName}:`, err); + } + } + + if (config.processedAction === "mark_read") { + await client.messageFlagsAdd(String(uid), ["\\Seen"], { uid: true }); + } else if (config.processedAction === "move") { + try { + await client.messageMove(String(uid), config.processedFolder, { uid: true }); + } catch { + log(`Could not move to "${config.processedFolder}", marking as read instead`); + await client.messageFlagsAdd(String(uid), ["\\Seen"], { uid: true }); + } + } else if (config.processedAction === "delete") { + await client.messageDelete(String(uid), { uid: true }); + } +} + +async function pollLoop(config: EmailConfig) { + if (!client || !watching) return; + + try { + const lock = await client.getMailboxLock(config.folder); + try { + const searchResult = await client.search({ seen: false }, { uid: true }); + const unseen = Array.isArray(searchResult) ? searchResult : []; + if (unseen.length > 0) { + log(`Found ${unseen.length} unseen message(s)`); + for (const uid of unseen) { + if (!watching) break; + try { + await handleMessage(client, uid, config); + } catch (err) { + logError(`Error handling message UID ${uid}:`, err); + } + } + } + } finally { + lock.release(); + } + } catch (err) { + logError("Error during poll:", err); + } + + if (!watching || !client) return; + + try { + await client.idle(); + } catch { + // IDLE interrupted or connection lost — reconnect handles it + } + + if (watching) { + setImmediate(() => pollLoop(config)); + } +} + +async function connect(config: EmailConfig) { + if (client) { + try { await client.logout(); } catch {} + client = null; + } + + client = new ImapFlow({ + host: config.host, + port: config.port, + secure: config.tls, + auth: { user: config.user, pass: config.pass }, + logger: false, + emitLogs: false, + }); + + client.on("error", (err: Error) => { + logError("Connection error:", err.message); + if (watching) scheduleReconnect(config); + }); + + client.on("close", () => { + log("Connection closed"); + if (watching) scheduleReconnect(config); + }); + + await client.connect(); + log(`Connected to ${config.host}:${config.port} as ${config.user}`); + + pollLoop(config); +} + +function scheduleReconnect(config: EmailConfig) { + if (reconnectTimer) return; + const delay = 10_000; + log(`Reconnecting in ${delay / 1000}s...`); + reconnectTimer = setTimeout(async () => { + reconnectTimer = null; + if (!watching) return; + try { + await connect(config); + } catch (err) { + logError("Reconnect failed:", err); + scheduleReconnect(config); + } + }, delay); +} + +export async function startEmailWatching(config: EmailConfig): Promise { + if (watching) await stopEmailWatching(); + + watching = true; + currentConfig = config; + + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + update: { emailWatching: true }, + create: { id: "singleton", emailWatching: true }, + }); + + await connect(config); +} + +export async function stopEmailWatching(): Promise { + watching = false; + currentConfig = null; + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + if (client) { + try { await client.logout(); } catch {} + client = null; + } + + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + update: { emailWatching: false }, + create: { id: "singleton", emailWatching: false }, + }); + + log("Stopped"); +} + +export function isEmailWatching(): boolean { + return watching; +} + +export function getEmailWatchStatus() { + return { + watching, + connected: client !== null && client.usable !== false, + config: currentConfig + ? { host: currentConfig.host, user: currentConfig.user, folder: currentConfig.folder } + : null, + }; +} + +export async function testEmailConnection(config: Omit): Promise<{ ok: boolean; folders?: string[]; error?: string }> { + let testClient: ImapFlow | null = null; + try { + testClient = new ImapFlow({ + host: config.host, + port: config.port, + secure: config.tls, + auth: { user: config.user, pass: config.pass }, + logger: false, + emitLogs: false, + }); + + await testClient.connect(); + const folders: string[] = []; + const list = await testClient.list(); + for (const folder of list) { + folders.push(folder.path); + } + + await testClient.logout(); + return { ok: true, folders }; + } catch (err) { + const message = err instanceof Error ? err.message : "Connection failed"; + return { ok: false, error: message }; + } finally { + if (testClient) { + try { await testClient.logout(); } catch {} + } + } +}