From 7e62ed739b4707e013972e69c993c64cddcd5edb Mon Sep 17 00:00:00 2001 From: Oleg Date: Sun, 26 Jul 2026 13:01:21 +0000 Subject: [PATCH] Add email channel: IMAP IDLE inbound, SMTP outbound, thread matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH --- package-lock.json | 507 ++++++++++++++++ package.json | 5 + src/app/(admin)/dashboard/ticket-board.tsx | 4 +- src/app/(admin)/nav-links.tsx | 5 +- .../settings/email/mailbox-settings-form.tsx | 158 +++++ src/app/(admin)/settings/email/page.tsx | 15 + src/app/api/mailbox/config/route.ts | 62 ++ src/app/api/tickets/[id]/messages/route.ts | 22 +- src/instrumentation.ts | 3 + .../db/migrations/0001_mushy_purple_man.sql | 15 + src/lib/db/migrations/meta/0001_snapshot.json | 547 ++++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 7 + src/lib/db/schema.ts | 25 +- src/lib/mail/config.ts | 70 +++ src/lib/mail/imap.ts | 148 +++++ src/lib/mail/smtp.ts | 49 ++ src/lib/tickets/service.ts | 102 ++++ src/lib/tickets/types.ts | 2 +- 18 files changed, 1739 insertions(+), 7 deletions(-) create mode 100644 src/app/(admin)/settings/email/mailbox-settings-form.tsx create mode 100644 src/app/(admin)/settings/email/page.tsx create mode 100644 src/app/api/mailbox/config/route.ts create mode 100644 src/lib/db/migrations/0001_mushy_purple_man.sql create mode 100644 src/lib/db/migrations/meta/0001_snapshot.json create mode 100644 src/lib/mail/config.ts create mode 100644 src/lib/mail/imap.ts create mode 100644 src/lib/mail/smtp.ts diff --git a/package-lock.json b/package-lock.json index 5d3ac26..4efdc35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,11 @@ "better-sqlite3": "^12.11.1", "drizzle-orm": "^0.45.2", "framer-motion": "^12.4.7", + "imapflow": "^1.5.0", "lucide-react": "^0.545.0", + "mailparser": "^3.9.14", "next": "16.2.12", + "nodemailer": "^9.0.3", "react": "19.2.4", "react-dom": "19.2.4", "telegraf": "^4.16.3", @@ -22,7 +25,9 @@ "devDependencies": { "@tailwindcss/postcss": "^4.1.16", "@types/better-sqlite3": "^7.6.13", + "@types/mailparser": "^3.4.6", "@types/node": "^20", + "@types/nodemailer": "^8.0.1", "@types/react": "^19", "@types/react-dom": "^19", "drizzle-kit": "^0.31.10", @@ -2023,6 +2028,11 @@ "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": { "version": "4.62.2", "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==", "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": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2676,6 +2701,28 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "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": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -2685,6 +2732,15 @@ "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": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -3391,6 +3447,16 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -3660,6 +3726,14 @@ "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": { "version": "1.0.7", "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==", "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": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -4227,6 +4309,57 @@ "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": { "version": "0.31.10", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", @@ -4392,6 +4525,14 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "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": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -4413,6 +4554,17 @@ "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": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -5545,6 +5697,14 @@ "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": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -5560,6 +5720,68 @@ "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5588,6 +5810,22 @@ "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5637,6 +5875,14 @@ "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": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6189,6 +6435,14 @@ "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": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6202,6 +6456,27 @@ "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": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -6451,6 +6726,24 @@ "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": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6516,6 +6809,23 @@ "@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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6817,6 +7127,14 @@ "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": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6932,6 +7250,14 @@ "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": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7025,6 +7351,18 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7063,6 +7401,14 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7080,6 +7426,40 @@ "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": { "version": "1.1.0", "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_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": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7181,6 +7576,14 @@ "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": { "version": "1.2.3", "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": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -7261,6 +7669,14 @@ "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": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7500,6 +7916,19 @@ "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": { "version": "2.0.2", "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", "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": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -7764,6 +8204,36 @@ "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": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7791,6 +8261,14 @@ "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": { "version": "0.0.5", "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_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": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -8173,6 +8667,14 @@ "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": { "version": "5.0.1", "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" } }, + "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index 777141a..352ccfc 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,11 @@ "better-sqlite3": "^12.11.1", "drizzle-orm": "^0.45.2", "framer-motion": "^12.4.7", + "imapflow": "^1.5.0", "lucide-react": "^0.545.0", + "mailparser": "^3.9.14", "next": "16.2.12", + "nodemailer": "^9.0.3", "react": "19.2.4", "react-dom": "19.2.4", "telegraf": "^4.16.3", @@ -28,7 +31,9 @@ "devDependencies": { "@tailwindcss/postcss": "^4.1.16", "@types/better-sqlite3": "^7.6.13", + "@types/mailparser": "^3.4.6", "@types/node": "^20", + "@types/nodemailer": "^8.0.1", "@types/react": "^19", "@types/react-dom": "^19", "drizzle-kit": "^0.31.10", diff --git a/src/app/(admin)/dashboard/ticket-board.tsx b/src/app/(admin)/dashboard/ticket-board.tsx index 3c8786d..8b1c1a8 100644 --- a/src/app/(admin)/dashboard/ticket-board.tsx +++ b/src/app/(admin)/dashboard/ticket-board.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from "react"; import Link from "next/link"; 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 { formatRelativeTime } from "@/lib/format"; import type { TicketDTO, TicketStatus } from "@/lib/tickets/types"; @@ -16,7 +16,7 @@ const COLUMNS: { status: TicketStatus; label: string }[] = [ { 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 = { low: "var(--text-faint)", diff --git a/src/app/(admin)/nav-links.tsx b/src/app/(admin)/nav-links.tsx index 2f4e151..acf4ce4 100644 --- a/src/app/(admin)/nav-links.tsx +++ b/src/app/(admin)/nav-links.tsx @@ -2,11 +2,12 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { LayoutDashboard, Settings } from "lucide-react"; +import { LayoutDashboard, Send, Mail } from "lucide-react"; const links = [ { 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() { diff --git a/src/app/(admin)/settings/email/mailbox-settings-form.tsx b/src/app/(admin)/settings/email/mailbox-settings-form.tsx new file mode 100644 index 0000000..1c04479 --- /dev/null +++ b/src/app/(admin)/settings/email/mailbox-settings-form.tsx @@ -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(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 ( +
+
+ {status.enabled ? ( + <> + + Подключено: {status.user} + + ) : ( + <> + + Почта не подключена + + )} +
+ +
+ + +
+ + +
+ + + + + + + + {error &&

{error}

} + +
+ + {status.enabled && ( + + )} +
+
+
+ ); +} diff --git a/src/app/(admin)/settings/email/page.tsx b/src/app/(admin)/settings/email/page.tsx new file mode 100644 index 0000000..d6783c5 --- /dev/null +++ b/src/app/(admin)/settings/email/page.tsx @@ -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 ( +
+

Почта

+

+ Письма на этот ящик будут превращаться в заявки, ответы агентов уходят обратно по SMTP. +

+ +
+ ); +} diff --git a/src/app/api/mailbox/config/route.ts b/src/app/api/mailbox/config/route.ts new file mode 100644 index 0000000..6d92022 --- /dev/null +++ b/src/app/api/mailbox/config/route.ts @@ -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 }); +} diff --git a/src/app/api/tickets/[id]/messages/route.ts b/src/app/api/tickets/[id]/messages/route.ts index d31ae3e..0f383a8 100644 --- a/src/app/api/tickets/[id]/messages/route.ts +++ b/src/app/api/tickets/[id]/messages/route.ts @@ -3,8 +3,9 @@ export const runtime = "nodejs"; import { NextResponse } from "next/server"; import { z } from "zod"; 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 { sendTicketReplyEmail } from "@/lib/mail/smtp"; import { db } from "@/lib/db/client"; import { customers } from "@/lib/db/schema"; 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 }); } + 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({ ticketId: id, agentId: session.user.id, agentName: session.user.name, body: parsed.data.body, + emailMessageId, }); if (result.ticket.channel === "telegram") { diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 2269ffa..e0d3191 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -2,5 +2,8 @@ export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { const { ensureTelegramBotStarted } = await import("@/lib/telegram/bot"); await ensureTelegramBotStarted(); + + const { ensureMailListenerStarted } = await import("@/lib/mail/imap"); + await ensureMailListenerStarted(); } } diff --git a/src/lib/db/migrations/0001_mushy_purple_man.sql b/src/lib/db/migrations/0001_mushy_purple_man.sql new file mode 100644 index 0000000..29cce7f --- /dev/null +++ b/src/lib/db/migrations/0001_mushy_purple_man.sql @@ -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; \ No newline at end of file diff --git a/src/lib/db/migrations/meta/0001_snapshot.json b/src/lib/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..c3385be --- /dev/null +++ b/src/lib/db/migrations/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json index 784a4d1..22bad30 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1785068349476, "tag": "0000_superb_the_fallen", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1785070407727, + "tag": "0001_mushy_purple_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 61667e4..381689f 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -63,7 +63,7 @@ export const tickets = sqliteTable("tickets", { priority: text("priority", { enum: ["low", "normal", "high", "urgent"] }) .notNull() .default("normal"), - channel: text("channel", { enum: ["telegram", "portal", "manual"] }).notNull(), + channel: text("channel", { enum: ["telegram", "portal", "manual", "email"] }).notNull(), customerId: text("customer_id") .notNull() .references(() => customers.id, { onDelete: "cascade" }), @@ -85,6 +85,10 @@ export const messages = sqliteTable("messages", { authorName: text("author_name").notNull(), body: text("body").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, }); @@ -97,3 +101,22 @@ export const telegramConfig = sqliteTable("telegram_config", { verifiedAt: integer("verified_at", { mode: "timestamp_ms" }), 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, +}); diff --git a/src/lib/mail/config.ts b/src/lib/mail/config.ts new file mode 100644 index 0000000..4e78d10 --- /dev/null +++ b/src/lib/mail/config.ts @@ -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 { + 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 { + 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 { + 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, + }; +} diff --git a/src/lib/mail/imap.ts b/src/lib/mail/imap.ts new file mode 100644 index 0000000..4e9d638 --- /dev/null +++ b/src/lib/mail/imap.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await stopCurrentListener(); + await disableMailboxConfig(); +} diff --git a/src/lib/mail/smtp.ts b/src/lib/mail/smtp.ts new file mode 100644 index 0000000..1f1b37e --- /dev/null +++ b/src/lib/mail/smtp.ts @@ -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 { + 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 { + 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; +} diff --git a/src/lib/tickets/service.ts b/src/lib/tickets/service.ts index 6ecbb03..3b8d236 100644 --- a/src/lib/tickets/service.ts +++ b/src/lib/tickets/service.ts @@ -55,6 +55,7 @@ async function appendMessage(params: { authorName: string; body: string; direction: "in" | "out"; + emailMessageId?: string; }) { const [message] = await db .insert(messages) @@ -65,6 +66,7 @@ async function appendMessage(params: { authorName: params.authorName, body: params.body, direction: params.direction, + emailMessageId: params.emailMessageId, }) .returning(); @@ -99,6 +101,35 @@ export async function findOrCreateCustomerByTelegramChatId(chatId: string, displ 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 { + 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. */ async function findOpenTicketForCustomer(customerId: string) { 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). */ export async function appendCustomerReply(params: { ticketId: string; @@ -177,6 +256,7 @@ export async function recordAgentReply(params: { agentId: string; agentName: string; body: string; + emailMessageId?: string; }) { const existing = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) }); if (!existing) throw new Error("Ticket not found"); @@ -192,6 +272,7 @@ export async function recordAgentReply(params: { authorName: params.agentName, body: params.body, 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() { return db.query.users.findMany({ orderBy: users.name }); } diff --git a/src/lib/tickets/types.ts b/src/lib/tickets/types.ts index 69b24c4..1a72430 100644 --- a/src/lib/tickets/types.ts +++ b/src/lib/tickets/types.ts @@ -1,6 +1,6 @@ export type TicketStatus = "new" | "open" | "pending" | "closed"; 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 MessageDirection = "in" | "out";