Add email channel: IMAP IDLE inbound, SMTP outbound, thread matching
Inbound mail on support@top-sysops.ru creates/updates tickets in realtime (IMAP IDLE, not polling). Replies thread via In-Reply-To/References against stored Message-IDs, falling back to the customer's open ticket. Mailbox credentials configured and encrypted via Settings -> Почта, same pattern as the Telegram channel. TLS verification is opt-in-skippable per mailbox (mail.top-sysops.ru's cert is currently expired; LAN-only server). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
cf0b6cf58e
commit
7e62ed739b
18 files changed
+1739
-7
No files matched your search
Generated
+507
@@ -12,8 +12,11 @@
|
|||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"framer-motion": "^12.4.7",
|
"framer-motion": "^12.4.7",
|
||||||
|
"imapflow": "^1.5.0",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
|
"mailparser": "^3.9.14",
|
||||||
"next": "16.2.12",
|
"next": "16.2.12",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"telegraf": "^4.16.3",
|
"telegraf": "^4.16.3",
|
||||||
@@ -22,7 +25,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.16",
|
"@tailwindcss/postcss": "^4.1.16",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
@@ -2023,6 +2028,11 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.62.2",
|
"version": "4.62.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
|
||||||
@@ -2354,6 +2364,21 @@
|
|||||||
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
|
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@selderee/plugin-htmlparser2": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "~2.3.0",
|
||||||
|
"domhandler": "~5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"selderee": "~0.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -2676,6 +2701,28 @@
|
|||||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"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,
|
||||||
|
"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,
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.43",
|
"version": "20.19.43",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||||
@@ -2685,6 +2732,15 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
@@ -3391,6 +3447,16 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@zone-eu/mailsplit": {
|
||||||
|
"version": "5.4.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz",
|
||||||
|
"integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==",
|
||||||
|
"dependencies": {
|
||||||
|
"libbase64": "1.3.0",
|
||||||
|
"libmime": "5.4.1",
|
||||||
|
"libqp": "2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/abort-controller": {
|
"node_modules/abort-controller": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||||
@@ -3660,6 +3726,14 @@
|
|||||||
"node": ">= 0.4"
|
"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==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/available-typed-arrays": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
@@ -4173,6 +4247,14 @@
|
|||||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/deepmerge-ts": {
|
||||||
|
"version": "7.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||||
|
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/define-data-property": {
|
"node_modules/define-data-property": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
@@ -4227,6 +4309,57 @@
|
|||||||
"node": ">=0.10.0"
|
"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==",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"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==",
|
||||||
|
"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/drizzle-kit": {
|
"node_modules/drizzle-kit": {
|
||||||
"version": "0.31.10",
|
"version": "0.31.10",
|
||||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
||||||
@@ -4392,6 +4525,14 @@
|
|||||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/end-of-stream": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
@@ -4413,6 +4554,17 @@
|
|||||||
"node": ">=10.13.0"
|
"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==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-abstract": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.24.2",
|
"version": "1.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||||
@@ -5545,6 +5697,14 @@
|
|||||||
"node": ">= 0.4"
|
"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==",
|
||||||
|
"bin": {
|
||||||
|
"he": "bin/he"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hermes-estree": {
|
"node_modules/hermes-estree": {
|
||||||
"version": "0.25.1",
|
"version": "0.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||||
@@ -5560,6 +5720,68 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-to-text": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@selderee/plugin-htmlparser2": "~0.12.0",
|
||||||
|
"deepmerge-ts": "^7.1.5",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"htmlparser2": "^10.1.0",
|
||||||
|
"selderee": "~0.12.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"entities": "^7.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2/node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
|
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -5588,6 +5810,22 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/imapflow": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-ayj2xIpRpXT9nXlAQQDhfm694faQxEmfAzRYL881q3YGRR5ofyIWsG3l3Lf7oSThxgLwZ/EQ5kh/nLnKBud3LQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@zone-eu/mailsplit": "5.4.14",
|
||||||
|
"encoding-japanese": "2.2.0",
|
||||||
|
"iconv-lite": "0.7.3",
|
||||||
|
"libbase64": "1.3.0",
|
||||||
|
"libmime": "5.4.1",
|
||||||
|
"libqp": "2.1.1",
|
||||||
|
"nodemailer": "9.0.3",
|
||||||
|
"pino": "10.3.1",
|
||||||
|
"socks": "2.8.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -5637,6 +5875,14 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
|
||||||
|
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@@ -6189,6 +6435,14 @@
|
|||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leac": {
|
||||||
|
"version": "0.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz",
|
||||||
|
"integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -6202,6 +6456,27 @@
|
|||||||
"node": ">= 0.8.0"
|
"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=="
|
||||||
|
},
|
||||||
|
"node_modules/libmime": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==",
|
||||||
|
"dependencies": {
|
||||||
|
"encoding-japanese": "2.2.0",
|
||||||
|
"iconv-lite": "0.7.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=="
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -6451,6 +6726,24 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/markdown-it"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -6516,6 +6809,23 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mailparser": {
|
||||||
|
"version": "3.9.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.14.tgz",
|
||||||
|
"integrity": "sha512-3QD6TRXcyXtq2NCuyA2AEjqmallQkyxYmZI9GMCIvQDCaB9Uc034WUI1x8RUBYFnk5+p7h14JEz4O/lrxQmttw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@zone-eu/mailsplit": "5.4.14",
|
||||||
|
"encoding-japanese": "2.2.0",
|
||||||
|
"he": "1.2.0",
|
||||||
|
"html-to-text": "10.0.0",
|
||||||
|
"iconv-lite": "0.7.3",
|
||||||
|
"libmime": "5.4.1",
|
||||||
|
"linkify-it": "5.0.2",
|
||||||
|
"nodemailer": "9.0.3",
|
||||||
|
"punycode.js": "2.3.1",
|
||||||
|
"tlds": "1.261.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -6817,6 +7127,14 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -6932,6 +7250,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/once": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -7025,6 +7351,18 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parseley": {
|
||||||
|
"version": "0.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",
|
||||||
|
"integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==",
|
||||||
|
"dependencies": {
|
||||||
|
"leac": "^0.7.0",
|
||||||
|
"peberminta": "^0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -7063,6 +7401,14 @@
|
|||||||
"node": ">= 14.16"
|
"node": ">= 14.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/peberminta": {
|
||||||
|
"version": "0.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz",
|
||||||
|
"integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -7080,6 +7426,40 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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==",
|
||||||
|
"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==",
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -7152,6 +7532,21 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -7181,6 +7576,14 @@
|
|||||||
"node": ">=6"
|
"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==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -7201,6 +7604,11 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
"node_modules/rc": {
|
"node_modules/rc": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||||
@@ -7261,6 +7669,14 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -7500,6 +7916,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||||
|
},
|
||||||
"node_modules/sandwich-stream": {
|
"node_modules/sandwich-stream": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/sandwich-stream/-/sandwich-stream-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/sandwich-stream/-/sandwich-stream-2.0.2.tgz",
|
||||||
@@ -7513,6 +7942,17 @@
|
|||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/selderee": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
|
||||||
|
"dependencies": {
|
||||||
|
"parseley": "~0.13.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@@ -7764,6 +8204,36 @@
|
|||||||
"simple-concat": "^1.0.0"
|
"simple-concat": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0",
|
||||||
|
"npm": ">= 3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socks": {
|
||||||
|
"version": "2.8.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
||||||
|
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
|
||||||
|
"dependencies": {
|
||||||
|
"ip-address": "^10.1.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==",
|
||||||
|
"dependencies": {
|
||||||
|
"atomic-sleep": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
@@ -7791,6 +8261,14 @@
|
|||||||
"source-map": "^0.6.0"
|
"source-map": "^0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stable-hash": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -8089,6 +8567,22 @@
|
|||||||
"node": "^12.20.0 || >=14.13.1"
|
"node": "^12.20.0 || >=14.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/thread-stream": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"real-require": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/thread-stream/node_modules/real-require": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -8173,6 +8667,14 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tlds": {
|
||||||
|
"version": "1.261.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
|
||||||
|
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
|
||||||
|
"bin": {
|
||||||
|
"tlds": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -8839,6 +9341,11 @@
|
|||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.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=="
|
||||||
|
},
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||||
|
|||||||
@@ -18,8 +18,11 @@
|
|||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"framer-motion": "^12.4.7",
|
"framer-motion": "^12.4.7",
|
||||||
|
"imapflow": "^1.5.0",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
|
"mailparser": "^3.9.14",
|
||||||
"next": "16.2.12",
|
"next": "16.2.12",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"telegraf": "^4.16.3",
|
"telegraf": "^4.16.3",
|
||||||
@@ -28,7 +31,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.16",
|
"@tailwindcss/postcss": "^4.1.16",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { Send, Globe, UserRound, Plus } from "lucide-react";
|
import { Send, Globe, UserRound, Mail, Plus } from "lucide-react";
|
||||||
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
||||||
import { formatRelativeTime } from "@/lib/format";
|
import { formatRelativeTime } from "@/lib/format";
|
||||||
import type { TicketDTO, TicketStatus } from "@/lib/tickets/types";
|
import type { TicketDTO, TicketStatus } from "@/lib/tickets/types";
|
||||||
@@ -16,7 +16,7 @@ const COLUMNS: { status: TicketStatus; label: string }[] = [
|
|||||||
{ status: "closed", label: "Закрытые" },
|
{ status: "closed", label: "Закрытые" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CHANNEL_ICON = { telegram: Send, portal: Globe, manual: UserRound } as const;
|
const CHANNEL_ICON = { telegram: Send, portal: Globe, manual: UserRound, email: Mail } as const;
|
||||||
|
|
||||||
const PRIORITY_COLOR: Record<TicketDTO["priority"], string> = {
|
const PRIORITY_COLOR: Record<TicketDTO["priority"], string> = {
|
||||||
low: "var(--text-faint)",
|
low: "var(--text-faint)",
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { LayoutDashboard, Settings } from "lucide-react";
|
import { LayoutDashboard, Send, Mail } from "lucide-react";
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: "/dashboard", label: "Дашборд", icon: LayoutDashboard },
|
{ href: "/dashboard", label: "Дашборд", icon: LayoutDashboard },
|
||||||
{ href: "/settings/telegram", label: "Telegram", icon: Settings },
|
{ href: "/settings/telegram", label: "Telegram", icon: Send },
|
||||||
|
{ href: "/settings/email", label: "Почта", icon: Mail },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function NavLinks() {
|
export function NavLinks() {
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CheckCircle2, XCircle } from "lucide-react";
|
||||||
|
|
||||||
|
interface Status {
|
||||||
|
configured: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
user: string | null;
|
||||||
|
imapHost: string | null;
|
||||||
|
allowInsecureTls: boolean;
|
||||||
|
verifiedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }) {
|
||||||
|
const [status, setStatus] = useState(initialStatus);
|
||||||
|
const [host, setHost] = useState(initialStatus.imapHost ?? "");
|
||||||
|
const [imapPort, setImapPort] = useState("993");
|
||||||
|
const [smtpPort, setSmtpPort] = useState("587");
|
||||||
|
const [user, setUser] = useState(initialStatus.user ?? "");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [allowInsecureTls, setAllowInsecureTls] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleConnect(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch("/api/mailbox/config", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
host,
|
||||||
|
imapPort: Number(imapPort),
|
||||||
|
smtpPort: Number(smtpPort),
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
allowInsecureTls,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось подключить почту");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus({ configured: true, enabled: true, user, imapHost: host, allowInsecureTls, verifiedAt: Date.now() });
|
||||||
|
setPassword("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisable() {
|
||||||
|
setLoading(true);
|
||||||
|
await fetch("/api/mailbox/config", { method: "DELETE" });
|
||||||
|
setLoading(false);
|
||||||
|
setStatus((s) => ({ ...s, enabled: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="mb-4 flex items-center gap-2 text-sm">
|
||||||
|
{status.enabled ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 size={16} className="text-success" />
|
||||||
|
<span>Подключено: {status.user}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircle size={16} className="text-text-faint" />
|
||||||
|
<span className="text-text-muted">Почта не подключена</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleConnect}>
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Хост (IMAP и SMTP)</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)}
|
||||||
|
placeholder="mail.top-sysops.ru или 10.33.33.42"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">IMAP порт</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={imapPort}
|
||||||
|
onChange={(e) => setImapPort(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">SMTP порт</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={smtpPort}
|
||||||
|
onChange={(e) => setSmtpPort(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Логин</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
value={user}
|
||||||
|
onChange={(e) => setUser(e.target.value)}
|
||||||
|
placeholder="support@top-sysops.ru"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allowInsecureTls}
|
||||||
|
onChange={(e) => setAllowInsecureTls(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Не проверять TLS-сертификат сервера (нужно, пока сертификат просрочен)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||||
|
{status.configured ? "Обновить" : "Подключить"}
|
||||||
|
</button>
|
||||||
|
{status.enabled && (
|
||||||
|
<button type="button" onClick={handleDisable} disabled={loading} className="btn btn-ghost">
|
||||||
|
Отключить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { getMailboxStatus } from "@/lib/mail/config";
|
||||||
|
import { MailboxSettingsForm } from "./mailbox-settings-form";
|
||||||
|
|
||||||
|
export default async function EmailSettingsPage() {
|
||||||
|
const status = await getMailboxStatus();
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<h1 className="mb-1 text-xl font-bold tracking-tight">Почта</h1>
|
||||||
|
<p className="mb-6 text-sm text-text-muted">
|
||||||
|
Письма на этот ящик будут превращаться в заявки, ответы агентов уходят обратно по SMTP.
|
||||||
|
</p>
|
||||||
|
<MailboxSettingsForm initialStatus={status} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { getMailboxStatus } from "@/lib/mail/config";
|
||||||
|
import { configureMailbox, disableMailListener } from "@/lib/mail/imap";
|
||||||
|
import { verifySmtp } from "@/lib/mail/smtp";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
return NextResponse.json(await getMailboxStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
const configureSchema = z.object({
|
||||||
|
host: z.string().min(1),
|
||||||
|
imapPort: z.coerce.number().int().positive(),
|
||||||
|
smtpPort: z.coerce.number().int().positive(),
|
||||||
|
user: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
allowInsecureTls: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = configureSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
imapHost: parsed.data.host,
|
||||||
|
imapPort: parsed.data.imapPort,
|
||||||
|
smtpHost: parsed.data.host,
|
||||||
|
smtpPort: parsed.data.smtpPort,
|
||||||
|
user: parsed.data.user,
|
||||||
|
password: parsed.data.password,
|
||||||
|
allowInsecureTls: parsed.data.allowInsecureTls,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await verifySmtp(settings);
|
||||||
|
await configureMailbox(settings);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
return NextResponse.json({ error: `Не удалось подключиться: ${message}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
await disableMailListener();
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ export const runtime = "nodejs";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireSession } from "@/lib/auth/require";
|
import { requireSession } from "@/lib/auth/require";
|
||||||
import { recordAgentReply } from "@/lib/tickets/service";
|
import { recordAgentReply, getEmailThreadContext } from "@/lib/tickets/service";
|
||||||
import { sendTelegramMessage } from "@/lib/telegram/bot";
|
import { sendTelegramMessage } from "@/lib/telegram/bot";
|
||||||
|
import { sendTicketReplyEmail } from "@/lib/mail/smtp";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { customers } from "@/lib/db/schema";
|
import { customers } from "@/lib/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -24,11 +25,30 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
|||||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ticket = await db.query.tickets.findFirst({ where: (t, { eq: eqOp }) => eqOp(t.id, id) });
|
||||||
|
if (!ticket) {
|
||||||
|
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let emailMessageId: string | undefined;
|
||||||
|
if (ticket.channel === "email") {
|
||||||
|
const context = await getEmailThreadContext(id);
|
||||||
|
if (context) {
|
||||||
|
emailMessageId = await sendTicketReplyEmail({
|
||||||
|
to: context.customerEmail,
|
||||||
|
subject: context.subject,
|
||||||
|
body: parsed.data.body,
|
||||||
|
inReplyTo: context.inReplyTo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await recordAgentReply({
|
const result = await recordAgentReply({
|
||||||
ticketId: id,
|
ticketId: id,
|
||||||
agentId: session.user.id,
|
agentId: session.user.id,
|
||||||
agentName: session.user.name,
|
agentName: session.user.name,
|
||||||
body: parsed.data.body,
|
body: parsed.data.body,
|
||||||
|
emailMessageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.ticket.channel === "telegram") {
|
if (result.ticket.channel === "telegram") {
|
||||||
|
|||||||
@@ -2,5 +2,8 @@ export async function register() {
|
|||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
const { ensureTelegramBotStarted } = await import("@/lib/telegram/bot");
|
const { ensureTelegramBotStarted } = await import("@/lib/telegram/bot");
|
||||||
await ensureTelegramBotStarted();
|
await ensureTelegramBotStarted();
|
||||||
|
|
||||||
|
const { ensureMailListenerStarted } = await import("@/lib/mail/imap");
|
||||||
|
await ensureMailListenerStarted();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE `mailbox_config` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`imap_host` text NOT NULL,
|
||||||
|
`imap_port` integer DEFAULT 993 NOT NULL,
|
||||||
|
`smtp_host` text NOT NULL,
|
||||||
|
`smtp_port` integer DEFAULT 587 NOT NULL,
|
||||||
|
`user` text NOT NULL,
|
||||||
|
`password_enc` text NOT NULL,
|
||||||
|
`allow_insecure_tls` integer DEFAULT false NOT NULL,
|
||||||
|
`enabled` integer DEFAULT false NOT NULL,
|
||||||
|
`verified_at` integer,
|
||||||
|
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `messages` ADD `email_message_id` text;
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "5e7b8e52-00cf-4515-91e1-c6afdc990797",
|
||||||
|
"prevId": "32259727-b523-472c-96c8-bf1260f7ac56",
|
||||||
|
"tables": {
|
||||||
|
"customers": {
|
||||||
|
"name": "customers",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"telegram_chat_id": {
|
||||||
|
"name": "telegram_chat_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"portal_token": {
|
||||||
|
"name": "portal_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"customers_telegram_chat_id_unique": {
|
||||||
|
"name": "customers_telegram_chat_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"telegram_chat_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"customers_portal_token_unique": {
|
||||||
|
"name": "customers_portal_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"portal_token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"mailbox_config": {
|
||||||
|
"name": "mailbox_config",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"imap_host": {
|
||||||
|
"name": "imap_host",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"imap_port": {
|
||||||
|
"name": "imap_port",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 993
|
||||||
|
},
|
||||||
|
"smtp_host": {
|
||||||
|
"name": "smtp_host",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"smtp_port": {
|
||||||
|
"name": "smtp_port",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 587
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_enc": {
|
||||||
|
"name": "password_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"allow_insecure_tls": {
|
||||||
|
"name": "allow_insecure_tls",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"verified_at": {
|
||||||
|
"name": "verified_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"name": "messages",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ticket_id": {
|
||||||
|
"name": "ticket_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"author_type": {
|
||||||
|
"name": "author_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"author_id": {
|
||||||
|
"name": "author_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"author_name": {
|
||||||
|
"name": "author_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"name": "body",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
"name": "direction",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_message_id": {
|
||||||
|
"name": "email_message_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"messages_ticket_id_tickets_id_fk": {
|
||||||
|
"name": "messages_ticket_id_tickets_id_fk",
|
||||||
|
"tableFrom": "messages",
|
||||||
|
"tableTo": "tickets",
|
||||||
|
"columnsFrom": [
|
||||||
|
"ticket_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"telegram_config": {
|
||||||
|
"name": "telegram_config",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"bot_token_enc": {
|
||||||
|
"name": "bot_token_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"bot_username": {
|
||||||
|
"name": "bot_username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"verified_at": {
|
||||||
|
"name": "verified_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tickets": {
|
||||||
|
"name": "tickets",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"name": "subject",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'new'"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"name": "priority",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'normal'"
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"name": "channel",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"customer_id": {
|
||||||
|
"name": "customer_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"assignee_id": {
|
||||||
|
"name": "assignee_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_message_at": {
|
||||||
|
"name": "last_message_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"tickets_customer_id_customers_id_fk": {
|
||||||
|
"name": "tickets_customer_id_customers_id_fk",
|
||||||
|
"tableFrom": "tickets",
|
||||||
|
"tableTo": "customers",
|
||||||
|
"columnsFrom": [
|
||||||
|
"customer_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"tickets_assignee_id_users_id_fk": {
|
||||||
|
"name": "tickets_assignee_id_users_id_fk",
|
||||||
|
"tableFrom": "tickets",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"assignee_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'agent'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
"when": 1785068349476,
|
"when": 1785068349476,
|
||||||
"tag": "0000_superb_the_fallen",
|
"tag": "0000_superb_the_fallen",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785070407727,
|
||||||
|
"tag": "0001_mushy_purple_man",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+24
-1
@@ -63,7 +63,7 @@ export const tickets = sqliteTable("tickets", {
|
|||||||
priority: text("priority", { enum: ["low", "normal", "high", "urgent"] })
|
priority: text("priority", { enum: ["low", "normal", "high", "urgent"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("normal"),
|
.default("normal"),
|
||||||
channel: text("channel", { enum: ["telegram", "portal", "manual"] }).notNull(),
|
channel: text("channel", { enum: ["telegram", "portal", "manual", "email"] }).notNull(),
|
||||||
customerId: text("customer_id")
|
customerId: text("customer_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => customers.id, { onDelete: "cascade" }),
|
.references(() => customers.id, { onDelete: "cascade" }),
|
||||||
@@ -85,6 +85,10 @@ export const messages = sqliteTable("messages", {
|
|||||||
authorName: text("author_name").notNull(),
|
authorName: text("author_name").notNull(),
|
||||||
body: text("body").notNull(),
|
body: text("body").notNull(),
|
||||||
direction: text("direction", { enum: ["in", "out"] }).notNull(),
|
direction: text("direction", { enum: ["in", "out"] }).notNull(),
|
||||||
|
// RFC 5322 Message-ID of this email, when the message came in/out over the
|
||||||
|
// email channel — lets a customer's future reply be matched back to the
|
||||||
|
// exact ticket via In-Reply-To/References, even if they have other tickets.
|
||||||
|
emailMessageId: text("email_message_id"),
|
||||||
createdAt: timestamps.createdAt,
|
createdAt: timestamps.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,3 +101,22 @@ export const telegramConfig = sqliteTable("telegram_config", {
|
|||||||
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
|
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
|
||||||
createdAt: timestamps.createdAt,
|
createdAt: timestamps.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Single-row-ish config for the email channel (one support mailbox per deployment). */
|
||||||
|
export const mailboxConfig = sqliteTable("mailbox_config", {
|
||||||
|
id: id(),
|
||||||
|
imapHost: text("imap_host").notNull(),
|
||||||
|
imapPort: integer("imap_port").notNull().default(993),
|
||||||
|
smtpHost: text("smtp_host").notNull(),
|
||||||
|
smtpPort: integer("smtp_port").notNull().default(587),
|
||||||
|
user: text("user").notNull(),
|
||||||
|
passwordEnc: text("password_enc").notNull(),
|
||||||
|
// Both IMAP and SMTP presented an expired cert on support@top-sysops.ru at
|
||||||
|
// setup time. This is a LAN-only mailbox (never exposed to the internet),
|
||||||
|
// so skipping verification is an accepted risk — not a default for
|
||||||
|
// arbitrary/public mail servers. Revisit once the cert is renewed.
|
||||||
|
allowInsecureTls: integer("allow_insecure_tls", { mode: "boolean" }).notNull().default(false),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
|
||||||
|
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: timestamps.createdAt,
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { mailboxConfig } from "@/lib/db/schema";
|
||||||
|
import { encryptCredential, decryptCredential } from "@/lib/crypto/credentials";
|
||||||
|
|
||||||
|
export interface MailboxSettings {
|
||||||
|
imapHost: string;
|
||||||
|
imapPort: number;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
allowInsecureTls: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings for the running listener/sender — null if never configured or disabled. */
|
||||||
|
export async function getMailboxSettings(): Promise<MailboxSettings | null> {
|
||||||
|
const config = await db.query.mailboxConfig.findFirst();
|
||||||
|
if (!config || !config.enabled) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
imapHost: config.imapHost,
|
||||||
|
imapPort: config.imapPort,
|
||||||
|
smtpHost: config.smtpHost,
|
||||||
|
smtpPort: config.smtpPort,
|
||||||
|
user: config.user,
|
||||||
|
password: decryptCredential(config.passwordEnc),
|
||||||
|
allowInsecureTls: config.allowInsecureTls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveMailboxSettings(settings: MailboxSettings): Promise<void> {
|
||||||
|
const row = {
|
||||||
|
imapHost: settings.imapHost,
|
||||||
|
imapPort: settings.imapPort,
|
||||||
|
smtpHost: settings.smtpHost,
|
||||||
|
smtpPort: settings.smtpPort,
|
||||||
|
user: settings.user,
|
||||||
|
passwordEnc: encryptCredential(settings.password),
|
||||||
|
allowInsecureTls: settings.allowInsecureTls,
|
||||||
|
enabled: true,
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = await db.query.mailboxConfig.findFirst();
|
||||||
|
if (existing) {
|
||||||
|
await db.update(mailboxConfig).set(row).where(eq(mailboxConfig.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(mailboxConfig).values(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableMailboxConfig(): Promise<void> {
|
||||||
|
const existing = await db.query.mailboxConfig.findFirst();
|
||||||
|
if (existing) {
|
||||||
|
await db.update(mailboxConfig).set({ enabled: false }).where(eq(mailboxConfig.id, existing.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMailboxStatus() {
|
||||||
|
const config = await db.query.mailboxConfig.findFirst();
|
||||||
|
return {
|
||||||
|
configured: Boolean(config),
|
||||||
|
enabled: Boolean(config?.enabled),
|
||||||
|
user: config?.user ?? null,
|
||||||
|
imapHost: config?.imapHost ?? null,
|
||||||
|
allowInsecureTls: config?.allowInsecureTls ?? false,
|
||||||
|
verifiedAt: config?.verifiedAt?.getTime() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { ImapFlow } from "imapflow";
|
||||||
|
import { simpleParser, type ParsedMail } from "mailparser";
|
||||||
|
import { getMailboxSettings, saveMailboxSettings, disableMailboxConfig, type MailboxSettings } from "@/lib/mail/config";
|
||||||
|
import { recordEmailInboundMessage } from "@/lib/tickets/service";
|
||||||
|
|
||||||
|
interface ListenerState {
|
||||||
|
client: ImapFlow | null;
|
||||||
|
stopping: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Survives Next.js dev-mode module reloads — one live IDLE connection per
|
||||||
|
// mailbox, not one per HMR reload.
|
||||||
|
const globalForImap = globalThis as unknown as { mailListenerState?: ListenerState };
|
||||||
|
const state: ListenerState = globalForImap.mailListenerState ?? { client: null, stopping: false };
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForImap.mailListenerState = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClient(settings: MailboxSettings): ImapFlow {
|
||||||
|
return new ImapFlow({
|
||||||
|
host: settings.imapHost,
|
||||||
|
port: settings.imapPort,
|
||||||
|
secure: settings.imapPort !== 143, // 993 = implicit TLS, 143 = STARTTLS
|
||||||
|
auth: { user: settings.user, pass: settings.password },
|
||||||
|
tls: settings.allowInsecureTls ? { rejectUnauthorized: false } : undefined,
|
||||||
|
logger: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBody(parsed: ParsedMail): string {
|
||||||
|
if (parsed.text) return parsed.text;
|
||||||
|
if (typeof parsed.html === "string") {
|
||||||
|
return parsed.html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
return "(пустое письмо)";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRawMessage(source: Buffer): Promise<void> {
|
||||||
|
const parsed = await simpleParser(source);
|
||||||
|
const from = parsed.from?.value[0];
|
||||||
|
if (!from?.address) return; // no return address — nothing to reply to, skip
|
||||||
|
|
||||||
|
const referencedIds = [
|
||||||
|
...(parsed.inReplyTo ? [parsed.inReplyTo] : []),
|
||||||
|
...(Array.isArray(parsed.references) ? parsed.references : parsed.references ? [parsed.references] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
await recordEmailInboundMessage({
|
||||||
|
fromEmail: from.address,
|
||||||
|
fromName: from.name || from.address,
|
||||||
|
subject: parsed.subject ?? "",
|
||||||
|
body: extractBody(parsed),
|
||||||
|
messageId: parsed.messageId ?? null,
|
||||||
|
referencedMessageIds: referencedIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processUnseenMessages(client: ImapFlow): Promise<void> {
|
||||||
|
const lock = await client.getMailboxLock("INBOX");
|
||||||
|
try {
|
||||||
|
const uids = await client.search({ seen: false }, { uid: true });
|
||||||
|
if (!uids || uids.length === 0) return;
|
||||||
|
|
||||||
|
for await (const message of client.fetch(uids, { source: true, uid: true })) {
|
||||||
|
if (message.source) {
|
||||||
|
await handleRawMessage(message.source);
|
||||||
|
}
|
||||||
|
await client.messageFlagsAdd(message.uid, ["\\Seen"], { uid: true });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lock.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runIdleLoop(client: ImapFlow): Promise<void> {
|
||||||
|
try {
|
||||||
|
await client.mailboxOpen("INBOX");
|
||||||
|
await processUnseenMessages(client);
|
||||||
|
|
||||||
|
while (state.client === client) {
|
||||||
|
await client.idle();
|
||||||
|
if (state.client !== client) break;
|
||||||
|
await processUnseenMessages(client);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (state.client === client) {
|
||||||
|
console.error("IMAP idle loop error, will retry shortly:", err);
|
||||||
|
state.client = null;
|
||||||
|
if (!state.stopping) {
|
||||||
|
setTimeout(() => {
|
||||||
|
ensureMailListenerStarted().catch((e) => console.error("Mail listener retry failed:", e));
|
||||||
|
}, 15_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopCurrentListener(): Promise<void> {
|
||||||
|
state.stopping = true;
|
||||||
|
const current = state.client;
|
||||||
|
state.client = null;
|
||||||
|
if (current) {
|
||||||
|
try {
|
||||||
|
await current.logout();
|
||||||
|
} catch {
|
||||||
|
// connection may already be dead — nothing more to do
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.stopping = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates settings by actually connecting before anything is persisted or replaced. */
|
||||||
|
async function connectAndOpenInbox(settings: MailboxSettings): Promise<ImapFlow> {
|
||||||
|
const client = createClient(settings);
|
||||||
|
await client.connect();
|
||||||
|
await client.mailboxOpen("INBOX");
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called once at process startup — resumes the IDLE listener if a mailbox was previously configured and enabled. */
|
||||||
|
export async function ensureMailListenerStarted(): Promise<void> {
|
||||||
|
const settings = await getMailboxSettings();
|
||||||
|
if (!settings) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await connectAndOpenInbox(settings);
|
||||||
|
state.client = client;
|
||||||
|
void runIdleLoop(client);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to start mail listener from stored config:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates + saves new mailbox settings and (re)starts the listener. Throws if the connection fails. */
|
||||||
|
export async function configureMailbox(settings: MailboxSettings): Promise<void> {
|
||||||
|
const client = await connectAndOpenInbox(settings); // validates before touching the running listener
|
||||||
|
|
||||||
|
await stopCurrentListener();
|
||||||
|
state.client = client;
|
||||||
|
void runIdleLoop(client);
|
||||||
|
|
||||||
|
await saveMailboxSettings(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableMailListener(): Promise<void> {
|
||||||
|
await stopCurrentListener();
|
||||||
|
await disableMailboxConfig();
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import { getMailboxSettings, type MailboxSettings } from "@/lib/mail/config";
|
||||||
|
|
||||||
|
function createTransport(settings: MailboxSettings) {
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: settings.smtpHost,
|
||||||
|
port: settings.smtpPort,
|
||||||
|
secure: settings.smtpPort === 465, // 465 = implicit TLS; 587/25 use STARTTLS
|
||||||
|
auth: { user: settings.user, pass: settings.password },
|
||||||
|
tls: settings.allowInsecureTls ? { rejectUnauthorized: false } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verifies SMTP credentials without sending anything — used to validate settings before saving. */
|
||||||
|
export async function verifySmtp(settings: MailboxSettings): Promise<void> {
|
||||||
|
await createTransport(settings).verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an agent's reply as an email, threaded onto the customer's previous
|
||||||
|
* message via In-Reply-To/References when we have one. Returns the
|
||||||
|
* Message-ID we generated, so the caller can store it for future threading.
|
||||||
|
*/
|
||||||
|
export async function sendTicketReplyEmail(params: {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
inReplyTo: string | null;
|
||||||
|
}): Promise<string> {
|
||||||
|
const settings = await getMailboxSettings();
|
||||||
|
if (!settings) throw new Error("Email channel is not configured");
|
||||||
|
|
||||||
|
const messageId = `<${crypto.randomUUID()}@${settings.user.split("@")[1] ?? "top-sysops.ru"}>`;
|
||||||
|
const subject = params.subject.toLowerCase().startsWith("re:") ? params.subject : `Re: ${params.subject}`;
|
||||||
|
|
||||||
|
await createTransport(settings).sendMail({
|
||||||
|
from: settings.user,
|
||||||
|
to: params.to,
|
||||||
|
subject,
|
||||||
|
text: params.body,
|
||||||
|
messageId,
|
||||||
|
...(params.inReplyTo
|
||||||
|
? { inReplyTo: params.inReplyTo, references: params.inReplyTo }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ async function appendMessage(params: {
|
|||||||
authorName: string;
|
authorName: string;
|
||||||
body: string;
|
body: string;
|
||||||
direction: "in" | "out";
|
direction: "in" | "out";
|
||||||
|
emailMessageId?: string;
|
||||||
}) {
|
}) {
|
||||||
const [message] = await db
|
const [message] = await db
|
||||||
.insert(messages)
|
.insert(messages)
|
||||||
@@ -65,6 +66,7 @@ async function appendMessage(params: {
|
|||||||
authorName: params.authorName,
|
authorName: params.authorName,
|
||||||
body: params.body,
|
body: params.body,
|
||||||
direction: params.direction,
|
direction: params.direction,
|
||||||
|
emailMessageId: params.emailMessageId,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -99,6 +101,35 @@ export async function findOrCreateCustomerByTelegramChatId(chatId: string, displ
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findOrCreateCustomerByEmail(email: string, displayName: string) {
|
||||||
|
const normalized = email.toLowerCase().trim();
|
||||||
|
const existing = await db.query.customers.findFirst({
|
||||||
|
where: eq(customers.email, normalized),
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(customers)
|
||||||
|
.values({ displayName, email: normalized })
|
||||||
|
.returning();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the ticket a reply belongs to by matching the email's In-Reply-To /
|
||||||
|
* References headers against Message-IDs we've previously sent or received.
|
||||||
|
* Falls back to null if none match (caller then uses the open-ticket
|
||||||
|
* find-or-create heuristic instead).
|
||||||
|
*/
|
||||||
|
async function findTicketIdByEmailReferences(candidateMessageIds: string[]): Promise<string | null> {
|
||||||
|
if (candidateMessageIds.length === 0) return null;
|
||||||
|
|
||||||
|
const match = await db.query.messages.findFirst({
|
||||||
|
where: (m, { inArray }) => inArray(m.emailMessageId, candidateMessageIds),
|
||||||
|
});
|
||||||
|
return match?.ticketId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** The customer's most recent ticket that isn't closed. */
|
/** The customer's most recent ticket that isn't closed. */
|
||||||
async function findOpenTicketForCustomer(customerId: string) {
|
async function findOpenTicketForCustomer(customerId: string) {
|
||||||
return db.query.tickets.findFirst({
|
return db.query.tickets.findFirst({
|
||||||
@@ -146,6 +177,54 @@ export async function recordTelegramInboundMessage(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records an inbound email. Prefers exact thread matching via
|
||||||
|
* In-Reply-To/References (so a reply to a specific closed ticket lands on
|
||||||
|
* that ticket, not a random other open one); falls back to the same
|
||||||
|
* find-or-create-open-ticket heuristic used for Telegram.
|
||||||
|
*/
|
||||||
|
export async function recordEmailInboundMessage(params: {
|
||||||
|
fromEmail: string;
|
||||||
|
fromName: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
messageId: string | null;
|
||||||
|
referencedMessageIds: string[];
|
||||||
|
}) {
|
||||||
|
const customer = await findOrCreateCustomerByEmail(params.fromEmail, params.fromName);
|
||||||
|
|
||||||
|
const matchedTicketId = await findTicketIdByEmailReferences(params.referencedMessageIds);
|
||||||
|
let ticket = matchedTicketId
|
||||||
|
? await db.query.tickets.findFirst({ where: eq(tickets.id, matchedTicketId) })
|
||||||
|
: await findOpenTicketForCustomer(customer.id);
|
||||||
|
|
||||||
|
let ticketIsNew = false;
|
||||||
|
if (!ticket) {
|
||||||
|
[ticket] = await db
|
||||||
|
.insert(tickets)
|
||||||
|
.values({
|
||||||
|
subject: params.subject || "(без темы)",
|
||||||
|
channel: "email" satisfies TicketChannel,
|
||||||
|
customerId: customer.id,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
ticketIsNew = true;
|
||||||
|
} else if (ticket.status === "closed") {
|
||||||
|
ticket = await touchTicket(ticket.id, { status: "open" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return appendMessage({
|
||||||
|
ticket,
|
||||||
|
ticketIsNew,
|
||||||
|
authorType: "customer",
|
||||||
|
authorId: customer.id,
|
||||||
|
authorName: params.fromName,
|
||||||
|
body: params.body,
|
||||||
|
direction: "in",
|
||||||
|
emailMessageId: params.messageId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Appends the customer's reply to a specific, already-known ticket (portal thread). */
|
/** Appends the customer's reply to a specific, already-known ticket (portal thread). */
|
||||||
export async function appendCustomerReply(params: {
|
export async function appendCustomerReply(params: {
|
||||||
ticketId: string;
|
ticketId: string;
|
||||||
@@ -177,6 +256,7 @@ export async function recordAgentReply(params: {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
agentName: string;
|
agentName: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
emailMessageId?: string;
|
||||||
}) {
|
}) {
|
||||||
const existing = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
|
const existing = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
|
||||||
if (!existing) throw new Error("Ticket not found");
|
if (!existing) throw new Error("Ticket not found");
|
||||||
@@ -192,6 +272,7 @@ export async function recordAgentReply(params: {
|
|||||||
authorName: params.agentName,
|
authorName: params.agentName,
|
||||||
body: params.body,
|
body: params.body,
|
||||||
direction: "out",
|
direction: "out",
|
||||||
|
emailMessageId: params.emailMessageId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +324,27 @@ export async function getTicketWithMessages(ticketId: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Recipient address + Message-ID to reply to, for sending an agent's email reply. */
|
||||||
|
export async function getEmailThreadContext(ticketId: string) {
|
||||||
|
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, ticketId) });
|
||||||
|
if (!ticket) return null;
|
||||||
|
|
||||||
|
const customer = await db.query.customers.findFirst({ where: eq(customers.id, ticket.customerId) });
|
||||||
|
if (!customer?.email) return null;
|
||||||
|
|
||||||
|
const lastEmailMessage = await db.query.messages.findFirst({
|
||||||
|
where: (m, { eq: eqOp, and: andOp, isNotNull }) =>
|
||||||
|
andOp(eqOp(m.ticketId, ticketId), isNotNull(m.emailMessageId)),
|
||||||
|
orderBy: desc(messages.createdAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerEmail: customer.email,
|
||||||
|
subject: ticket.subject,
|
||||||
|
inReplyTo: lastEmailMessage?.emailMessageId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function listAgents() {
|
export async function listAgents() {
|
||||||
return db.query.users.findMany({ orderBy: users.name });
|
return db.query.users.findMany({ orderBy: users.name });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type TicketStatus = "new" | "open" | "pending" | "closed";
|
export type TicketStatus = "new" | "open" | "pending" | "closed";
|
||||||
export type TicketPriority = "low" | "normal" | "high" | "urgent";
|
export type TicketPriority = "low" | "normal" | "high" | "urgent";
|
||||||
export type TicketChannel = "telegram" | "portal" | "manual";
|
export type TicketChannel = "telegram" | "portal" | "manual" | "email";
|
||||||
export type MessageAuthorType = "customer" | "agent" | "system";
|
export type MessageAuthorType = "customer" | "agent" | "system";
|
||||||
export type MessageDirection = "in" | "out";
|
export type MessageDirection = "in" | "out";
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user