Add IMAP email monitoring for scanned card attachments

Watches an email inbox via IMAP IDLE for incoming scanned cards,
extracts PDF/image attachments, and feeds them through the existing
OCR pipeline. Configurable via the Settings page with test connection
support.

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-07 11:41:52 -05:00
parent 3dd4dc16a3
commit 41000337e3
8 changed files with 1078 additions and 0 deletions

473
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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")
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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: {

View file

@ -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<NotificationPrefs>(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() {
</CardContent>
</Card>
<Card variant="glass">
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-base">
<Mail className="size-4 text-primary" />
Email Monitoring
</CardTitle>
<CardDescription>Watch an inbox for scanned card attachments</CardDescription>
</div>
<Badge
variant="secondary"
className={
emailTestStatus === "success"
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: emailTestStatus === "error"
? "bg-red-500/10 text-red-700 dark:text-red-300"
: ""
}
>
{emailTestStatus === "success" && <Wifi className="mr-1 size-3" />}
{emailTestStatus === "error" && <WifiOff className="mr-1 size-3" />}
{emailTestStatus === "testing" && <Loader2 className="mr-1 size-3 animate-spin" />}
{emailTestStatus === "success"
? "Connected"
: emailTestStatus === "error"
? "Error"
: emailTestStatus === "testing"
? "Testing..."
: "Not tested"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">IMAP Host</Label>
<Input
value={settings.emailImapHost}
onChange={(e) => setSettings((s) => ({ ...s, emailImapHost: e.target.value }))}
placeholder="imap.dreamhost.com"
/>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Port</Label>
<Input
type="number"
value={settings.emailImapPort}
onChange={(e) => setSettings((s) => ({ ...s, emailImapPort: parseInt(e.target.value) || 993 }))}
placeholder="993"
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Username</Label>
<Input
value={settings.emailImapUser}
onChange={(e) => setSettings((s) => ({ ...s, emailImapUser: e.target.value }))}
placeholder="echo-ocr@stillwell.cloud"
/>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Password</Label>
<Input
type="password"
value={settings.emailImapPass}
onChange={(e) => setSettings((s) => ({ ...s, emailImapPass: e.target.value }))}
placeholder="••••••••"
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Folder</Label>
<Input
value={settings.emailFolder}
onChange={(e) => setSettings((s) => ({ ...s, emailFolder: e.target.value }))}
placeholder="INBOX"
/>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">After Processing</Label>
<Select
value={settings.emailProcessed}
onValueChange={(val) => val && setSettings((s) => ({ ...s, emailProcessed: val }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mark_read">Mark as Read</SelectItem>
<SelectItem value="move">Move to Folder</SelectItem>
<SelectItem value="delete">Delete</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{settings.emailProcessed === "move" && (
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Move to Folder</Label>
<Input
value={settings.emailProcessedFolder}
onChange={(e) => setSettings((s) => ({ ...s, emailProcessedFolder: e.target.value }))}
placeholder="Processed"
/>
</div>
)}
<div className="flex items-center gap-3">
<Label className="text-xs font-medium text-muted-foreground">TLS / SSL</Label>
<Switch
checked={settings.emailImapTls}
onCheckedChange={(val: boolean) => setSettings((s) => ({ ...s, emailImapTls: val }))}
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={testEmailConnection}
disabled={emailTestStatus === "testing" || !settings.emailImapHost || !settings.emailImapUser || !settings.emailImapPass}
>
{emailTestStatus === "testing" ? (
<Loader2 className="mr-2 size-3 animate-spin" />
) : (
<Wifi className="mr-2 size-3" />
)}
Test Connection
</Button>
<Button
variant={settings.emailWatching ? "destructive" : "outline"}
size="sm"
className="rounded-xl"
onClick={toggleEmailWatch}
disabled={!settings.emailImapHost || !settings.emailImapUser || !settings.emailImapPass}
>
{settings.emailWatching ? "Stop Monitoring" : "Start Monitoring"}
</Button>
{settings.emailWatching && (
<Badge className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-300">
Active
</Badge>
)}
</div>
</CardContent>
</Card>
<Card variant="glass">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">

265
src/lib/email-watcher.ts Normal file
View file

@ -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<typeof setTimeout> | 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<void> {
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<void> {
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<EmailConfig, "processedAction" | "processedFolder">): 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 {}
}
}
}