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:
ogrechkoandClaude Sonnet 5 committed 2026-07-26 13:01:21 +00:00
1 parent cf0b6cf58e
commit 7e62ed739b
18 files changed
+1739 -7

No files matched your search

+507
View File
@@ -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",
+5
View File
@@ -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",
+2 -2
View File
@@ -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)",
+3 -2
View File
@@ -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>
);
}
+15
View File
@@ -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>
);
}
+62
View File
@@ -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 });
}
+21 -1
View File
@@ -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") {
+3
View File
@@ -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": {}
}
}
+7
View File
@@ -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
View File
@@ -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,
});
+70
View File
@@ -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,
};
}
+148
View File
@@ -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();
}
+49
View File
@@ -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;
}
+102
View File
@@ -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 -1
View File
@@ -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";