feat(history): complete messaging core before multi-account gateways

This commit is contained in:
wmq
2026-07-28 22:36:58 +08:00
parent cd06d049fe
commit 7f09132fad
54 changed files with 4781 additions and 601 deletions
+487
View File
@@ -12,7 +12,9 @@
"cors": "^2.8.5",
"express": "^5.1.0",
"helmet": "^8.1.0",
"imapflow": "^1.6.1",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.9.14",
"mammoth": "^1.9.0",
"nodemailer": "^7.0.5",
"pdf-parse": "^1.1.1",
@@ -20,6 +22,28 @@
"ws": "^8.18.3"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.12.0",
"resolved": "https://registry.npmmirror.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
"integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==",
"license": "MIT",
"dependencies": {
"domelementtype": "~2.3.0",
"domhandler": "~5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
},
"peerDependencies": {
"selderee": "~0.12.0"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
@@ -29,6 +53,17 @@
"node": ">=10.0.0"
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.14",
"resolved": "https://registry.npmmirror.com/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz",
"integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.4.1",
"libqp": "2.1.1"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz",
@@ -51,6 +86,15 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
@@ -244,6 +288,15 @@
}
}
},
"node_modules/deepmerge-ts": {
"version": "7.1.5",
"resolved": "https://registry.npmmirror.com/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
@@ -259,6 +312,61 @@
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
@@ -306,6 +414,27 @@
"node": ">= 0.8"
}
},
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -515,6 +644,15 @@
"node": ">= 0.4"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/helmet": {
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-8.3.0.tgz",
@@ -527,6 +665,56 @@
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/html-to-text": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/html-to-text/-/html-to-text-10.0.0.tgz",
"integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==",
"license": "MIT",
"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.npmmirror.com/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"
}
],
"license": "MIT",
"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.npmmirror.com/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
@@ -563,6 +751,22 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/imapflow": {
"version": "1.6.1",
"resolved": "https://registry.npmmirror.com/imapflow/-/imapflow-1.6.1.tgz",
"integrity": "sha512-lBkh0ZcKeYHDS3sWfxEcD7j24frrrn4wtbe0UUtLqdLwMSvm9Mygx8j7ZtzWgnvqI7V1DkNfcsidJ/qasb7FBA==",
"license": "MIT",
"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",
"pino": "10.3.1",
"socks": "2.8.9"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
@@ -575,6 +779,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.3.1",
"resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.3.1.tgz",
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -651,6 +864,39 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/leac": {
"version": "0.7.0",
"resolved": "https://registry.npmmirror.com/leac/-/leac-0.7.0.tgz",
"integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/libbase64": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/libbase64/-/libbase64-1.3.0.tgz",
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.4.1",
"resolved": "https://registry.npmmirror.com/libmime/-/libmime-5.4.1.tgz",
"integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==",
"license": "MIT",
"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.npmmirror.com/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
@@ -660,6 +906,25 @@
"immediate": "~3.0.5"
}
},
"node_modules/linkify-it": {
"version": "5.0.2",
"resolved": "https://registry.npmmirror.com/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"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -713,6 +978,33 @@
"underscore": "^1.13.1"
}
},
"node_modules/mailparser": {
"version": "3.9.14",
"resolved": "https://registry.npmmirror.com/mailparser/-/mailparser-3.9.14.tgz",
"integrity": "sha512-3QD6TRXcyXtq2NCuyA2AEjqmallQkyxYmZI9GMCIvQDCaB9Uc034WUI1x8RUBYFnk5+p7h14JEz4O/lrxQmttw==",
"license": "MIT",
"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/mailparser/node_modules/nodemailer": {
"version": "9.0.3",
"resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-9.0.3.tgz",
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
@@ -847,6 +1139,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
@@ -880,6 +1181,19 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parseley": {
"version": "0.13.1",
"resolved": "https://registry.npmmirror.com/parseley/-/parseley-0.13.1.tgz",
"integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==",
"license": "MIT",
"dependencies": {
"leac": "^0.7.0",
"peberminta": "^0.10.0"
},
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
@@ -924,6 +1238,15 @@
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/peberminta": {
"version": "0.10.0",
"resolved": "https://registry.npmmirror.com/peberminta/-/peberminta-0.10.0.tgz",
"integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmmirror.com/pg/-/pg-8.22.0.tgz",
@@ -1013,6 +1336,43 @@
"split2": "^4.1.0"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -1058,6 +1418,22 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1071,6 +1447,15 @@
"node": ">= 0.10"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
@@ -1087,6 +1472,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.3.0.tgz",
@@ -1136,6 +1527,15 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz",
@@ -1172,12 +1572,33 @@
],
"license": "MIT"
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/selderee": {
"version": "0.12.0",
"resolved": "https://registry.npmmirror.com/selderee/-/selderee-0.12.0.tgz",
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
"license": "MIT",
"dependencies": {
"parseley": "~0.13.1"
},
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
@@ -1319,6 +1740,39 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"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.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
@@ -1358,6 +1812,33 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz",
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
"license": "MIT",
"dependencies": {
"real-require": "^1.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/thread-stream/node_modules/real-require": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz",
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
"license": "MIT"
},
"node_modules/tlds": {
"version": "1.261.0",
"resolved": "https://registry.npmmirror.com/tlds/-/tlds-1.261.0.tgz",
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
"license": "MIT",
"bin": {
"tlds": "bin.js"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1398,6 +1879,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz",
+7 -3
View File
@@ -5,18 +5,22 @@
"type": "module",
"scripts": {
"start": "node src/index.js",
"test": "node --test test/business.test.js"
"test": "node --test test/business.test.js",
"test:email-protocol": "node --test test/email-protocol.test.js",
"test:email-api": "node --test test/email-api-e2e.test.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^5.1.0",
"helmet": "^8.1.0",
"imapflow": "^1.6.1",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.9.14",
"mammoth": "^1.9.0",
"nodemailer": "^7.0.5",
"pdf-parse": "^1.1.1",
"pg": "^8.13.1"
,"ws": "^8.18.3"
"pg": "^8.13.1",
"ws": "^8.18.3"
}
}
+41 -25
View File
@@ -10,32 +10,48 @@ function parseJson(text) {
export async function deepseek(system, user, json = false) {
const apiKey = process.env.DEEPSEEK_API_KEY
if (!apiKey) throw Object.assign(new Error('DEEPSEEK_API_KEY 尚未配置'), { status: 503 })
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000))
try {
const response = await fetch(`${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`, {
method: 'POST',
signal: controller.signal,
headers: {
'content-type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
max_tokens: Number(process.env.AI_MAX_TOKENS || 4096),
temperature: json ? 0.1 : 0.3,
system,
messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
}),
})
const body = await response.json()
if (!response.ok) throw Object.assign(new Error(body?.error?.message || `AI 请求失败 (${response.status})`), { status: 502 })
const text = contentText(body)
return json ? parseJson(text) : text
} finally {
clearTimeout(timeout)
const endpoint = `${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`
const attempts = json ? 2 : 1
let prompt = user
let lastError
for (let attempt = 0; attempt < attempts; attempt += 1) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000))
try {
const response = await fetch(endpoint, {
method: 'POST',
signal: controller.signal,
headers: {
'content-type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
max_tokens: Number(process.env.AI_MAX_TOKENS || 4096),
temperature: json ? 0 : 0.3,
system,
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
}),
})
const body = await response.json()
if (!response.ok) {
throw Object.assign(new Error(body?.error?.message || `AI 请求失败 (${response.status})`), { status: 502 })
}
const text = contentText(body)
if (!json) return text
try {
return parseJson(text)
} catch (error) {
lastError = error
prompt = `${user}\n\n上一次响应不是合法 JSON。请重新生成完整结果:只允许输出一个可被 JSON.parse 直接解析的 JSON 值;不得使用 Markdown;字符串中的双引号、换行和反斜杠必须正确转义。`
}
} finally {
clearTimeout(timeout)
}
}
throw Object.assign(new Error('AI 返回格式异常,自动重试后仍无法解析'), { status: 502, cause: lastError })
}
export function conversationContext(messages) {
+176
View File
@@ -0,0 +1,176 @@
import crypto from 'node:crypto'
import nodemailer from 'nodemailer'
import { ImapFlow } from 'imapflow'
import { simpleParser } from 'mailparser'
function encryptionKey() {
const secret = process.env.EMAIL_CREDENTIAL_KEY || process.env.JWT_SECRET
if (!secret) throw Object.assign(new Error('EMAIL_CREDENTIAL_KEY 或 JWT_SECRET 尚未配置'), { status: 503 })
return crypto.createHash('sha256').update(secret).digest()
}
export function encryptEmailPassword(password) {
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv)
const encrypted = Buffer.concat([cipher.update(String(password), 'utf8'), cipher.final()])
return `v1:${iv.toString('base64url')}:${cipher.getAuthTag().toString('base64url')}:${encrypted.toString('base64url')}`
}
export function decryptEmailPassword(value) {
const [version, ivValue, tagValue, encryptedValue] = String(value || '').split(':')
if (version !== 'v1' || !ivValue || !tagValue || !encryptedValue) {
throw Object.assign(new Error('Email 凭据无效,请重新连接邮箱'), { status: 409 })
}
const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivValue, 'base64url'))
decipher.setAuthTag(Buffer.from(tagValue, 'base64url'))
return Buffer.concat([
decipher.update(Buffer.from(encryptedValue, 'base64url')),
decipher.final(),
]).toString('utf8')
}
function imapClient(channel, password) {
return new ImapFlow({
host: channel.imapHost,
port: Number(channel.imapPort || 993),
secure: channel.imapSecure !== false,
auth: { user: channel.account, pass: password },
logger: false,
tls: { rejectUnauthorized: channel.allowInvalidCertificate !== true },
})
}
function smtpTransport(channel, password) {
return nodemailer.createTransport({
host: channel.smtpHost,
port: Number(channel.smtpPort || 465),
secure: channel.smtpSecure !== false,
auth: { user: channel.account, pass: password },
tls: { rejectUnauthorized: channel.allowInvalidCertificate !== true },
})
}
export async function verifyEmailConnection(channel, password) {
const startedAt = Date.now()
const imap = imapClient(channel, password)
try {
await imap.connect()
await imap.mailboxOpen(channel.imapFolder || 'INBOX', { readOnly: true })
} finally {
if (imap.usable) await imap.logout().catch(() => undefined)
}
const smtp = smtpTransport(channel, password)
await smtp.verify()
smtp.close()
return { latencyMs: Date.now() - startedAt }
}
function address(value) {
const item = value?.value?.[0]
return {
name: String(item?.name || item?.address || '').trim(),
email: String(item?.address || '').trim().toLowerCase(),
}
}
export async function fetchEmailMessages(channel, { full = false } = {}) {
const password = decryptEmailPassword(channel.encryptedPassword)
const client = imapClient(channel, password)
const messages = []
try {
await client.connect()
await client.mailboxOpen(channel.imapFolder || 'INBOX')
let uids
if (!full && Number(channel.lastUid || 0) > 0) {
uids = await client.search({ uid: `${Number(channel.lastUid) + 1}:*` }, { uid: true })
} else {
const days = Math.max(1, Math.min(365, Number(channel.syncDays || 30)))
uids = await client.search({ since: new Date(Date.now() - days * 86400000) }, { uid: true })
}
uids = (uids || []).slice(-500)
if (!uids.length) return { messages, lastUid: Number(channel.lastUid || 0) }
for await (const row of client.fetch(uids, {
uid: true,
envelope: true,
flags: true,
internalDate: true,
source: true,
}, { uid: true })) {
const parsed = await simpleParser(row.source)
const sender = address(parsed.from)
const recipients = parsed.to?.value || []
const attachments = (parsed.attachments || []).map((attachment, index) => ({
id: `${row.uid}-${index + 1}`,
name: attachment.filename || `attachment-${index + 1}`,
mimeType: attachment.contentType || 'application/octet-stream',
size: attachment.size || attachment.content?.length || 0,
contentId: attachment.contentId,
base64: attachment.content?.length <= 20 * 1024 * 1024
? attachment.content.toString('base64')
: undefined,
}))
messages.push({
uid: Number(row.uid),
messageId: String(parsed.messageId || `imap-${row.uid}`),
inReplyTo: parsed.inReplyTo || undefined,
references: Array.isArray(parsed.references) ? parsed.references : parsed.references ? [parsed.references] : [],
subject: String(parsed.subject || '(无主题)').trim(),
from: sender,
to: recipients.map((item) => ({ name: item.name || item.address || '', email: String(item.address || '').toLowerCase() })),
text: String(parsed.text || '').trim() || String(parsed.html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(),
html: typeof parsed.html === 'string' ? parsed.html : undefined,
date: (parsed.date || row.internalDate || new Date()).toISOString(),
seen: row.flags?.has('\\Seen') || false,
attachments,
})
}
return {
messages: messages.sort((a, b) => Date.parse(a.date) - Date.parse(b.date)),
lastUid: Math.max(Number(channel.lastUid || 0), ...messages.map((message) => message.uid)),
}
} finally {
if (client.usable) await client.logout().catch(() => undefined)
}
}
export async function sendEmailMessage(channel, {
to,
subject,
text,
html,
inReplyTo,
references,
attachments,
}) {
const password = decryptEmailPassword(channel.encryptedPassword)
const transport = smtpTransport(channel, password)
try {
return await transport.sendMail({
from: channel.displayName ? { name: channel.displayName, address: channel.account } : channel.account,
to,
subject,
text,
html,
inReplyTo,
references,
attachments,
})
} finally {
transport.close()
}
}
export async function markEmailUidsSeen(channel, uids) {
const values = [...new Set(uids.map(Number).filter(Number.isFinite))]
if (!values.length) return
const password = decryptEmailPassword(channel.encryptedPassword)
const client = imapClient(channel, password)
try {
await client.connect()
await client.mailboxOpen(channel.imapFolder || 'INBOX')
await client.messageFlagsAdd(values, ['\\Seen'], { uid: true })
} finally {
if (client.usable) await client.logout().catch(() => undefined)
}
}
+1070 -70
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -1,6 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js'
import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js'
test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => {
assert.deepEqual(
@@ -42,3 +43,12 @@ test('业务指标工具处理百分比、分组和无效日期', () => {
{ new: 2, won: 1 },
)
})
test('Email 渠道凭据使用认证加密且可正确解密', () => {
process.env.EMAIL_CREDENTIAL_KEY = 'lingxi-email-test-key-with-more-than-32-bytes'
const encrypted = encryptEmailPassword('app-password-123')
assert.notEqual(encrypted, 'app-password-123')
assert.match(encrypted, /^v1:/)
assert.equal(decryptEmailPassword(encrypted), 'app-password-123')
assert.throws(() => decryptEmailPassword(`${encrypted.slice(0, -1)}x`))
})
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
encryptEmailPassword,
fetchEmailMessages,
sendEmailMessage,
} from '../src/email.js'
const enabled = process.env.RUN_EMAIL_API_E2E === '1'
const baseUrl = process.env.EMAIL_API_E2E_BASE_URL || 'http://127.0.0.1:8088'
function localChannel(account) {
return {
account,
encryptedPassword: encryptEmailPassword('pwd'),
imapHost: '127.0.0.1',
imapPort: 3143,
imapSecure: false,
imapFolder: 'INBOX',
smtpHost: '127.0.0.1',
smtpPort: 3025,
smtpSecure: false,
syncDays: 7,
}
}
function unwrap(payload) {
return payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload
}
async function request(path, { token, method = 'GET', body } = {}) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
})
const text = await response.text()
const payload = text ? JSON.parse(text) : null
if (!response.ok) throw new Error(`${method} ${path} -> ${response.status}: ${payload?.message || text}`)
return unwrap(payload)
}
async function waitForSenderMessage(predicate) {
const sender = localChannel('sender@local.test')
for (let attempt = 0; attempt < 20; attempt += 1) {
const result = await fetchEmailMessages(sender, { full: true })
const message = result.messages.find(predicate)
if (message) return message
await new Promise((resolve) => setTimeout(resolve, 150))
}
throw new Error('等待网关发出的邮件进入 sender IMAP 超时')
}
test('真实 HTTP 网关完成 Email 连接、全量同步、附件读取、回复与已读', { skip: !enabled }, async () => {
process.env.JWT_SECRET ||= 'lingxi-email-api-e2e-secret'
const suffix = Date.now()
const senderChannel = localChannel('sender@local.test')
const olderSubject = `Lingxi sorting older ${suffix}`
const newerSubject = `Lingxi sorting newer ${suffix}`
await sendEmailMessage(senderChannel, {
to: 'receiver@local.test',
subject: olderSubject,
text: 'Older conversation used for stable ordering',
})
await new Promise((resolve) => setTimeout(resolve, 30))
await sendEmailMessage(senderChannel, {
to: 'receiver@local.test',
subject: newerSubject,
text: 'Newer conversation must remain above the opened older conversation',
})
const session = await request('/api/auth/register', {
method: 'POST',
body: {
name: 'Email API E2E',
account: `email-api-${suffix}@local.test`,
password: 'Lingxi-Email-Api-2026!',
company: 'Lingxi Protocol Lab',
industry: '跨境电商',
teamSize: '1-10人',
},
})
assert.ok(session.token)
const connected = await request('/api/channels/email/connect', {
token: session.token,
method: 'POST',
body: {
name: '本地协议邮箱',
displayName: 'Lingxi Receiver',
account: 'receiver@local.test',
password: 'pwd',
imapHost: 'host.docker.internal',
imapPort: 3143,
imapSecure: false,
imapFolder: 'INBOX',
smtpHost: 'host.docker.internal',
smtpPort: 3025,
smtpSecure: false,
syncDays: 7,
},
})
assert.equal(connected.status, 'connected')
assert.equal(connected.encryptedPassword, undefined)
const conversations = await request('/api/conversations?channel=email', { token: session.token })
assert.ok(conversations.list.length >= 2)
const newerConversation = conversations.list.find((item) => item.emailSubject === newerSubject)
const olderConversation = conversations.list.find((item) => item.emailSubject === olderSubject)
assert.ok(newerConversation)
assert.ok(olderConversation)
assert.ok(conversations.list.indexOf(newerConversation) < conversations.list.indexOf(olderConversation))
const olderMessages = await request(`/api/conversations/${olderConversation.id}/messages`, { token: session.token })
await request(`/api/conversations/${olderConversation.id}/read`, {
token: session.token,
method: 'POST',
body: { messageIds: olderMessages.map((message) => message.id) },
})
const afterRead = await request('/api/conversations?channel=email', { token: session.token })
const relevantOrder = afterRead.list
.filter((item) => [newerConversation.id, olderConversation.id].includes(item.id))
.map((item) => item.id)
assert.deepEqual(relevantOrder, [newerConversation.id, olderConversation.id])
let conversation
let incoming
for (const candidate of conversations.list.filter((item) => item.customer?.email === 'sender@local.test')) {
const candidateMessages = await request(`/api/conversations/${candidate.id}/messages`, { token: session.token })
const candidateIncoming = candidateMessages.find((message) => message.sender === 'customer' && message.emailAttachments?.length)
if (candidateIncoming) {
conversation = candidate
incoming = candidateIncoming
break
}
}
assert.ok(conversation)
assert.ok(incoming)
const attachment = await request(
`/api/conversations/${conversation.id}/messages/${incoming.id}/attachment?attachmentId=${encodeURIComponent(incoming.emailAttachments[0].id)}`,
{ token: session.token },
)
assert.equal(attachment.name, 'lingxi-proof.txt')
assert.ok(attachment.dataUrl.startsWith('data:text/plain;base64,'))
const replyText = `Lingxi gateway API reply ${suffix}`
const sent = await request(`/api/conversations/${conversation.id}/messages`, {
token: session.token,
method: 'POST',
body: { text: replyText, autoTranslate: false },
})
assert.equal(sent.status, 'sent')
assert.ok(sent.emailMessageId)
const receivedBySender = await waitForSenderMessage((message) => message.messageId === sent.emailMessageId)
assert.equal(receivedBySender.text, replyText)
assert.ok(receivedBySender.inReplyTo)
await request(`/api/conversations/${conversation.id}/read`, {
token: session.token,
method: 'POST',
body: { messageIds: [incoming.id] },
})
const receiver = await fetchEmailMessages(localChannel('receiver@local.test'), { full: true })
assert.equal(receiver.messages.find((message) => message.uid === incoming.emailUid)?.seen, true)
})
+86
View File
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
encryptEmailPassword,
fetchEmailMessages,
markEmailUidsSeen,
sendEmailMessage,
verifyEmailConnection,
} from '../src/email.js'
const enabled = process.env.RUN_EMAIL_PROTOCOL_TEST === '1'
function channel(account) {
return {
id: `protocol-${account}`,
type: 'email',
account,
displayName: account.startsWith('sender') ? 'Lingxi Sender' : 'Lingxi Receiver',
encryptedPassword: encryptEmailPassword('pwd'),
imapHost: process.env.EMAIL_TEST_HOST || '127.0.0.1',
imapPort: Number(process.env.EMAIL_TEST_IMAP_PORT || 3143),
imapSecure: false,
imapFolder: 'INBOX',
smtpHost: process.env.EMAIL_TEST_HOST || '127.0.0.1',
smtpPort: Number(process.env.EMAIL_TEST_SMTP_PORT || 3025),
smtpSecure: false,
syncDays: 7,
}
}
async function waitForMessage(target, predicate) {
for (let attempt = 0; attempt < 20; attempt += 1) {
const result = await fetchEmailMessages(target, { full: true })
const message = result.messages.find(predicate)
if (message) return { message, result }
await new Promise((resolve) => setTimeout(resolve, 150))
}
throw new Error('等待邮件进入 IMAP INBOX 超时')
}
test('真实 SMTP 发件、IMAP 收件、附件解析、已读与回复头', { skip: !enabled }, async () => {
process.env.JWT_SECRET ||= 'lingxi-email-protocol-test-secret'
const sender = channel('sender@local.test')
const receiver = channel('receiver@local.test')
const subject = `Lingxi protocol ${Date.now()}`
const senderCheck = await verifyEmailConnection(sender, 'pwd')
const receiverCheck = await verifyEmailConnection(receiver, 'pwd')
assert.ok(senderCheck.latencyMs >= 0)
assert.ok(receiverCheck.latencyMs >= 0)
assert.ok(!sender.encryptedPassword.includes('pwd'))
const sent = await sendEmailMessage(sender, {
to: receiver.account,
subject,
text: 'Lingxi IMAP/SMTP real protocol message',
attachments: [{
filename: 'lingxi-proof.txt',
content: Buffer.from('real email attachment'),
contentType: 'text/plain',
}],
})
assert.ok(sent.messageId)
const received = await waitForMessage(receiver, (message) => message.subject === subject)
assert.equal(received.message.text, 'Lingxi IMAP/SMTP real protocol message')
assert.equal(received.message.from.email, sender.account)
assert.equal(received.message.attachments.length, 1)
assert.equal(received.message.attachments[0].name, 'lingxi-proof.txt')
assert.equal(Buffer.from(received.message.attachments[0].base64, 'base64').toString(), 'real email attachment')
await markEmailUidsSeen(receiver, [received.message.uid])
const marked = await fetchEmailMessages(receiver, { full: true })
assert.equal(marked.messages.find((message) => message.uid === received.message.uid)?.seen, true)
const reply = await sendEmailMessage(receiver, {
to: sender.account,
subject: `Re: ${subject}`,
text: 'Reply received by Lingxi',
inReplyTo: received.message.messageId,
references: [received.message.messageId],
})
const replyReceived = await waitForMessage(sender, (message) => message.messageId === reply.messageId)
assert.equal(replyReceived.message.inReplyTo, received.message.messageId)
assert.ok(replyReceived.message.references.includes(received.message.messageId))
})