mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ADD - mail app
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-plugin-vue": "~10.2.0",
|
||||
"express": "^5.1.0",
|
||||
"prettier": "3.5.3",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.8.0",
|
||||
|
||||
Generated
+532
@@ -57,6 +57,9 @@ importers:
|
||||
eslint-plugin-vue:
|
||||
specifier: ~10.2.0
|
||||
version: 10.2.0(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)))
|
||||
express:
|
||||
specifier: ^5.1.0
|
||||
version: 5.2.1
|
||||
prettier:
|
||||
specifier: 3.5.3
|
||||
version: 3.5.3
|
||||
@@ -769,6 +772,10 @@ packages:
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@@ -810,6 +817,10 @@ packages:
|
||||
birpc@2.9.0:
|
||||
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
||||
|
||||
body-parser@2.3.0:
|
||||
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
@@ -827,10 +838,22 @@ packages:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
cac@6.7.14:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bound@1.0.4:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
callsites@3.1.0:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -866,6 +889,26 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
content-disposition@1.1.0:
|
||||
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
content-type@1.0.5:
|
||||
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
content-type@2.0.0:
|
||||
resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cookie-signature@1.2.2:
|
||||
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
||||
engines: {node: '>=6.6.0'}
|
||||
|
||||
cookie@0.7.2:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
copy-anything@4.0.5:
|
||||
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -905,13 +948,28 @@ packages:
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
encodeurl@2.0.0:
|
||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
enhanced-resolve@5.24.5:
|
||||
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -920,9 +978,21 @@ packages:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-errors@1.3.0:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild@0.28.1:
|
||||
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -932,6 +1002,9 @@ packages:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
escape-html@1.0.3:
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
|
||||
escape-string-regexp@4.0.0:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1023,10 +1096,18 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
etag@1.8.1:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
expect-type@1.4.0:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
express@5.2.1:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -1063,6 +1144,10 @@ packages:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
finalhandler@2.1.1:
|
||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
|
||||
find-up@5.0.0:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1074,15 +1159,34 @@ packages:
|
||||
flatted@3.4.4:
|
||||
resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
|
||||
|
||||
forwarded@0.2.0:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
fresh@2.0.0:
|
||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-proto@1.0.1:
|
||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -1095,6 +1199,10 @@ packages:
|
||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
@@ -1102,6 +1210,14 @@ packages:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.4:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
he@1.2.0:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
@@ -1109,6 +1225,14 @@ packages:
|
||||
hookable@5.5.3:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
iconv-lite@0.7.3:
|
||||
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -1125,6 +1249,13 @@ packages:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
is-extglob@2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1141,6 +1272,9 @@ packages:
|
||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
is-promise@4.0.0:
|
||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||
|
||||
is-what@5.5.0:
|
||||
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1268,6 +1402,18 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
media-typer@1.1.1:
|
||||
resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
merge-descriptors@2.0.0:
|
||||
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
merge2@1.4.1:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1276,6 +1422,14 @@ packages:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
mime-db@1.54.0:
|
||||
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@3.0.2:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
minimatch@10.2.6:
|
||||
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -1304,6 +1458,10 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
negotiator@1.0.0:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
nth-check@2.1.1:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
@@ -1311,6 +1469,17 @@ packages:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1327,6 +1496,10 @@ packages:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
@@ -1338,6 +1511,9 @@ packages:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
path-to-regexp@8.4.2:
|
||||
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
@@ -1389,13 +1565,29 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qs@6.15.3:
|
||||
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
range-parser@1.3.0:
|
||||
resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
raw-body@3.0.2:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1416,17 +1608,35 @@ packages:
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
router@2.2.0:
|
||||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
rxjs@7.8.2:
|
||||
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
semver@7.8.5:
|
||||
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
send@1.2.1:
|
||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
serve-static@2.2.1:
|
||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1439,6 +1649,22 @@ packages:
|
||||
resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-weakmap@1.0.2:
|
||||
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel@1.1.1:
|
||||
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
@@ -1453,6 +1679,10 @@ packages:
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
@@ -1523,6 +1753,10 @@ packages:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
toidentifier@1.0.1:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
tree-kill@1.2.2:
|
||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||
hasBin: true
|
||||
@@ -1540,6 +1774,10 @@ packages:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
type-is@2.1.0:
|
||||
resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
typescript-eslint@8.65.0:
|
||||
resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1555,6 +1793,10 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
unpipe@1.0.0:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
@@ -1684,6 +1926,9 @@ packages:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
xml-name-validator@4.0.0:
|
||||
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2329,6 +2574,11 @@ snapshots:
|
||||
typescript: 5.8.3
|
||||
vue: 3.5.40(typescript@5.8.3)
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
negotiator: 1.0.0
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.18.0):
|
||||
dependencies:
|
||||
acorn: 8.18.0
|
||||
@@ -2360,6 +2610,20 @@ snapshots:
|
||||
|
||||
birpc@2.9.0: {}
|
||||
|
||||
body-parser@2.3.0:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 2.0.0
|
||||
debug: 4.4.3
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.3
|
||||
on-finished: 2.4.1
|
||||
qs: 6.15.3
|
||||
raw-body: 3.0.2
|
||||
type-is: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@1.1.18:
|
||||
@@ -2379,8 +2643,20 @@ snapshots:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
call-bound@1.0.4:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
chai@5.3.3:
|
||||
@@ -2421,6 +2697,16 @@ snapshots:
|
||||
tree-kill: 1.2.2
|
||||
yargs: 17.7.2
|
||||
|
||||
content-disposition@1.1.0: {}
|
||||
|
||||
content-type@1.0.5: {}
|
||||
|
||||
content-type@2.0.0: {}
|
||||
|
||||
cookie-signature@1.2.2: {}
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
copy-anything@4.0.5:
|
||||
dependencies:
|
||||
is-what: 5.5.0
|
||||
@@ -2450,10 +2736,22 @@ snapshots:
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
encodeurl@2.0.0: {}
|
||||
|
||||
enhanced-resolve@5.24.5:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -2461,8 +2759,16 @@ snapshots:
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
esbuild@0.28.1:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.28.1
|
||||
@@ -2494,6 +2800,8 @@ snapshots:
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)):
|
||||
@@ -2609,8 +2917,43 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
express@5.2.1:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
body-parser: 2.3.0
|
||||
content-disposition: 1.1.0
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.2
|
||||
cookie-signature: 1.2.2
|
||||
debug: 4.4.3
|
||||
depd: 2.0.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
finalhandler: 2.1.1
|
||||
fresh: 2.0.0
|
||||
http-errors: 2.0.1
|
||||
merge-descriptors: 2.0.0
|
||||
mime-types: 3.0.2
|
||||
on-finished: 2.4.1
|
||||
once: 1.4.0
|
||||
parseurl: 1.3.3
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.15.3
|
||||
range-parser: 1.3.0
|
||||
router: 2.2.0
|
||||
send: 1.2.1
|
||||
serve-static: 2.2.1
|
||||
statuses: 2.0.2
|
||||
type-is: 2.1.0
|
||||
vary: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-diff@1.3.0: {}
|
||||
@@ -2643,6 +2986,17 @@ snapshots:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
finalhandler@2.1.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
on-finished: 2.4.1
|
||||
parseurl: 1.3.3
|
||||
statuses: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
find-up@5.0.0:
|
||||
dependencies:
|
||||
locate-path: 6.0.0
|
||||
@@ -2655,11 +3009,35 @@ snapshots:
|
||||
|
||||
flatted@3.4.4: {}
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.2
|
||||
function-bind: 1.1.2
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.4
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
get-proto@1.0.1:
|
||||
dependencies:
|
||||
dunder-proto: 1.0.1
|
||||
es-object-atoms: 1.1.2
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -2670,14 +3048,34 @@ snapshots:
|
||||
|
||||
globals@14.0.0: {}
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
inherits: 2.0.4
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
iconv-lite@0.7.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.6: {}
|
||||
@@ -2689,6 +3087,10 @@ snapshots:
|
||||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
@@ -2699,6 +3101,8 @@ snapshots:
|
||||
|
||||
is-number@7.0.0: {}
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
|
||||
is-what@5.5.0: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
@@ -2795,6 +3199,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
media-typer@1.1.1: {}
|
||||
|
||||
merge-descriptors@2.0.0: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
micromatch@4.0.8:
|
||||
@@ -2802,6 +3212,12 @@ snapshots:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.2
|
||||
|
||||
mime-db@1.54.0: {}
|
||||
|
||||
mime-types@3.0.2:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
minimatch@10.2.6:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.9
|
||||
@@ -2824,12 +3240,24 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
nth-check@2.1.1:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -2851,12 +3279,16 @@ snapshots:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
path-to-regexp@8.4.2: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pathval@2.0.1: {}
|
||||
@@ -2895,10 +3327,29 @@ snapshots:
|
||||
|
||||
prettier@3.5.3: {}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qs@6.15.3:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
side-channel: 1.1.1
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
range-parser@1.3.0: {}
|
||||
|
||||
raw-body@3.0.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.3
|
||||
unpipe: 1.0.0
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
@@ -2939,6 +3390,16 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.62.4
|
||||
fsevents: 2.3.3
|
||||
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
depd: 2.0.0
|
||||
is-promise: 4.0.0
|
||||
parseurl: 1.3.3
|
||||
path-to-regexp: 8.4.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
@@ -2947,8 +3408,37 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
fresh: 2.0.0
|
||||
http-errors: 2.0.1
|
||||
mime-types: 3.0.2
|
||||
ms: 2.1.3
|
||||
on-finished: 2.4.1
|
||||
range-parser: 1.3.0
|
||||
statuses: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
serve-static@2.2.1:
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
parseurl: 1.3.3
|
||||
send: 1.2.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
@@ -2957,6 +3447,34 @@ snapshots:
|
||||
|
||||
shell-quote@1.9.0: {}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-weakmap@1.0.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-map: 1.0.1
|
||||
|
||||
side-channel@1.1.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-list: 1.0.1
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
@@ -2965,6 +3483,8 @@ snapshots:
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
@@ -3024,6 +3544,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
|
||||
ts-api-utils@2.5.0(typescript@5.8.3):
|
||||
@@ -3036,6 +3558,12 @@ snapshots:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
type-is@2.1.0:
|
||||
dependencies:
|
||||
content-type: 2.0.0
|
||||
media-typer: 1.1.1
|
||||
mime-types: 3.0.2
|
||||
|
||||
typescript-eslint@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
|
||||
@@ -3051,6 +3579,8 @@ snapshots:
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
uri-js@4.4.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
@@ -3187,6 +3717,8 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
xml-name-validator@4.0.0: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 5.1 KiB |
@@ -2,7 +2,6 @@
|
||||
@import 'konsta/vue/theme.css';
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
@@ -21,7 +20,7 @@ body,
|
||||
min-height: 0 !important;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
background: transparent !important;
|
||||
user-select: none;
|
||||
}
|
||||
button,
|
||||
@@ -89,6 +88,10 @@ button {
|
||||
min-height: 0 !important;
|
||||
overflow: hidden;
|
||||
background: transparent !important;
|
||||
color-scheme: dark;
|
||||
}
|
||||
.phone-app--light {
|
||||
color-scheme: light;
|
||||
}
|
||||
.phone-notification {
|
||||
right: 8px !important;
|
||||
|
||||
@@ -11,6 +11,11 @@ describe('app registry', () => {
|
||||
expect(PHONE_APPS.every((app) => app.iconImage.endsWith('.webp'))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(PHONE_APPS.find((app) => app.id === 'mail')).toMatchObject({
|
||||
gridOrder: 3,
|
||||
labelKey: 'Apps.mail.name',
|
||||
route: '/apps/mail',
|
||||
})
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Camera,
|
||||
Clock3,
|
||||
Images,
|
||||
Mail,
|
||||
NotebookPen,
|
||||
Settings,
|
||||
ShoppingBag,
|
||||
@@ -13,6 +14,7 @@ import appStoreIcon from '@/assets/img/app-icons/apps.webp'
|
||||
import calculatorIcon from '@/assets/img/app-icons/calculator.webp'
|
||||
import cameraIcon from '@/assets/img/app-icons/camera.webp'
|
||||
import clockIcon from '@/assets/img/app-icons/clock.webp'
|
||||
import mailIcon from '@/assets/img/app-icons/mail.webp'
|
||||
import notesIcon from '@/assets/img/app-icons/notes.webp'
|
||||
import photosIcon from '@/assets/img/app-icons/gallery.webp'
|
||||
import settingsIcon from '@/assets/img/app-icons/settings.webp'
|
||||
@@ -21,10 +23,23 @@ import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
|
||||
export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/NotesApp.vue')),
|
||||
defineAsyncComponent(() => import('@/views/apps/MailApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 3,
|
||||
icon: markRaw(Mail),
|
||||
iconClass: '',
|
||||
iconImage: mailIcon,
|
||||
id: 'mail',
|
||||
labelKey: 'Apps.mail.name',
|
||||
route: '/apps/mail',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/NotesApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 4,
|
||||
icon: markRaw(NotebookPen),
|
||||
iconClass: '',
|
||||
iconImage: notesIcon,
|
||||
@@ -76,7 +91,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
defineAsyncComponent(() => import('@/views/apps/PhotosApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 4,
|
||||
gridOrder: 5,
|
||||
icon: markRaw(Images),
|
||||
iconClass: 'app-icon--photos',
|
||||
iconImage: photosIcon,
|
||||
@@ -89,7 +104,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
defineAsyncComponent(() => import('@/views/apps/AppStoreApp.vue')),
|
||||
),
|
||||
dockOrder: 0,
|
||||
gridOrder: 5,
|
||||
gridOrder: 6,
|
||||
icon: markRaw(ShoppingBag),
|
||||
iconClass: 'app-icon--store',
|
||||
iconImage: appStoreIcon,
|
||||
@@ -102,7 +117,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
defineAsyncComponent(() => import('@/views/apps/SettingsApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 6,
|
||||
gridOrder: 7,
|
||||
icon: markRaw(Settings),
|
||||
iconClass: 'app-icon--settings',
|
||||
iconImage: settingsIcon,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import type { MailCounts, MailListItem } from '@/types/mail'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({
|
||||
nuiCall: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
const counts: MailCounts = {
|
||||
drafts: 1,
|
||||
inbox: 2,
|
||||
sent: 3,
|
||||
trash: 4,
|
||||
unread: 1,
|
||||
}
|
||||
|
||||
function listItem(id: number): MailListItem {
|
||||
return {
|
||||
created_at: '2026-08-04 10:00:00',
|
||||
folder: 'inbox',
|
||||
id,
|
||||
is_read: false,
|
||||
message_id: `00000000-0000-0000-0000-${String(id).padStart(12, '0')}`,
|
||||
preview: 'Body',
|
||||
recipients: ['alex@ifruit.com'],
|
||||
sender: 'morgan@ifruit.com',
|
||||
subject: 'Plans',
|
||||
}
|
||||
}
|
||||
|
||||
describe('mail store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('tracks search state and appends paginated mailbox results', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({
|
||||
data: { hasMore: true, items: [listItem(1)] },
|
||||
success: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { hasMore: false, items: [listItem(2)] },
|
||||
success: true,
|
||||
})
|
||||
|
||||
const mail = useMailStore()
|
||||
await mail.loadFolder('inbox', 'plans')
|
||||
await mail.loadFolder('inbox', 'plans', true)
|
||||
|
||||
expect(mail.search).toBe('plans')
|
||||
expect(mail.items.map((item) => item.id)).toEqual([1, 2])
|
||||
expect(mail.hasMore).toBe(false)
|
||||
expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'mail:list', {
|
||||
folder: 'inbox',
|
||||
offset: 1,
|
||||
search: 'plans',
|
||||
})
|
||||
})
|
||||
|
||||
it('saves draft content and refreshes mailbox counts', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({ data: { id: 'draft-id' }, success: true })
|
||||
.mockResolvedValueOnce({ data: counts, success: true })
|
||||
|
||||
const mail = useMailStore()
|
||||
const id = await mail.saveDraft({
|
||||
body: 'Body',
|
||||
recipients: ['alex@ifruit.com'],
|
||||
subject: 'Subject',
|
||||
})
|
||||
|
||||
expect(id).toBe('draft-id')
|
||||
expect(mail.counts).toEqual(counts)
|
||||
expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'mail:save-draft', {
|
||||
body: 'Body',
|
||||
id: undefined,
|
||||
recipients: ['alex@ifruit.com'],
|
||||
subject: 'Subject',
|
||||
})
|
||||
})
|
||||
|
||||
it('logs out the active session and resets mailbox state', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({
|
||||
data: { counts, email: 'alex@ifruit.com' },
|
||||
success: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { hasMore: true, items: [listItem(1)] },
|
||||
success: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ success: true })
|
||||
|
||||
const mail = useMailStore()
|
||||
await mail.login('alex', 'secret')
|
||||
await mail.loadFolder('sent', 'plans')
|
||||
await mail.logout()
|
||||
|
||||
expect(mockNuiCall).toHaveBeenLastCalledWith('mail:logout')
|
||||
expect(mail.accountEmail).toBe('')
|
||||
expect(mail.folder).toBe('inbox')
|
||||
expect(mail.search).toBe('')
|
||||
expect(mail.items).toEqual([])
|
||||
expect(mail.hasMore).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type {
|
||||
MailComposeDraft,
|
||||
MailCounts,
|
||||
MailDraft,
|
||||
MailFolder,
|
||||
MailListItem,
|
||||
MailListResponse,
|
||||
MailMessage,
|
||||
MailSession,
|
||||
} from '@/types/mail'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
const emptyCounts = (): MailCounts => ({
|
||||
drafts: 0,
|
||||
inbox: 0,
|
||||
sent: 0,
|
||||
trash: 0,
|
||||
unread: 0,
|
||||
})
|
||||
|
||||
export const useMailStore = defineStore('mail', () => {
|
||||
const accountEmail = ref('')
|
||||
const counts = ref<MailCounts>(emptyCounts())
|
||||
const folder = ref<MailFolder>('inbox')
|
||||
const hasMore = ref(false)
|
||||
const items = ref<MailListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
|
||||
function applySession(session: MailSession): void {
|
||||
accountEmail.value = session.email
|
||||
counts.value = session.counts
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const response = await nuiCall<MailSession>('mail:login', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
if (response.success && response.data) applySession(response.data)
|
||||
return response
|
||||
}
|
||||
|
||||
async function register(email: string, password: string) {
|
||||
const response = await nuiCall<MailSession>('mail:register', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
if (response.success && response.data) applySession(response.data)
|
||||
return response
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
if (accountEmail.value) await nuiCall('mail:logout')
|
||||
accountEmail.value = ''
|
||||
counts.value = emptyCounts()
|
||||
items.value = []
|
||||
hasMore.value = false
|
||||
folder.value = 'inbox'
|
||||
search.value = ''
|
||||
}
|
||||
|
||||
async function loadFolder(
|
||||
nextFolder: MailFolder,
|
||||
nextSearch = '',
|
||||
append = false,
|
||||
): Promise<boolean> {
|
||||
loading.value = true
|
||||
const offset = append ? items.value.length : 0
|
||||
const response = await nuiCall<MailListResponse>('mail:list', {
|
||||
folder: nextFolder,
|
||||
offset,
|
||||
search: nextSearch,
|
||||
})
|
||||
loading.value = false
|
||||
if (!response.success || !response.data) return false
|
||||
|
||||
folder.value = nextFolder
|
||||
search.value = nextSearch
|
||||
items.value = append
|
||||
? [...items.value, ...response.data.items]
|
||||
: response.data.items
|
||||
hasMore.value = response.data.hasMore
|
||||
return true
|
||||
}
|
||||
|
||||
async function refreshCounts(): Promise<void> {
|
||||
const response = await nuiCall<MailCounts>('mail:counts')
|
||||
if (response.success && response.data) counts.value = response.data
|
||||
}
|
||||
|
||||
async function openMessage(id: number): Promise<MailMessage | null> {
|
||||
const response = await nuiCall<MailMessage>('mail:get', { id })
|
||||
if (!response.success || !response.data) return null
|
||||
await refreshCounts()
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function openDraft(id: string): Promise<MailDraft | null> {
|
||||
const response = await nuiCall<MailDraft>('mail:get-draft', { id })
|
||||
return response.success && response.data ? response.data : null
|
||||
}
|
||||
|
||||
async function saveDraft(draft: MailComposeDraft): Promise<string | null> {
|
||||
const response = await nuiCall<{ id: string }>('mail:save-draft', {
|
||||
body: draft.body,
|
||||
id: draft.id,
|
||||
recipients: draft.recipients,
|
||||
subject: draft.subject,
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
await refreshCounts()
|
||||
return response.data.id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function deleteDraft(id: string): Promise<boolean> {
|
||||
const response = await nuiCall('mail:delete-draft', { id })
|
||||
if (response.success) await refreshCounts()
|
||||
return response.success
|
||||
}
|
||||
|
||||
async function send(draft: MailComposeDraft) {
|
||||
const response = await nuiCall<{ id: string }>('mail:send', {
|
||||
body: draft.body,
|
||||
draftId: draft.id,
|
||||
recipients: draft.recipients,
|
||||
subject: draft.subject,
|
||||
})
|
||||
if (response.success) await refreshCounts()
|
||||
return response
|
||||
}
|
||||
|
||||
async function mutateEntry(
|
||||
endpoint: string,
|
||||
id: number,
|
||||
extra: Record<string, unknown> = {},
|
||||
): Promise<boolean> {
|
||||
const response = await nuiCall(endpoint, { id, ...extra })
|
||||
if (response.success) {
|
||||
await Promise.all([
|
||||
refreshCounts(),
|
||||
loadFolder(folder.value, search.value),
|
||||
])
|
||||
}
|
||||
return response.success
|
||||
}
|
||||
|
||||
async function emptyTrash(): Promise<boolean> {
|
||||
const response = await nuiCall('mail:empty-trash')
|
||||
if (response.success) {
|
||||
await Promise.all([refreshCounts(), loadFolder('trash', search.value)])
|
||||
}
|
||||
return response.success
|
||||
}
|
||||
|
||||
return {
|
||||
accountEmail,
|
||||
counts,
|
||||
deleteDraft,
|
||||
emptyTrash,
|
||||
folder,
|
||||
hasMore,
|
||||
items,
|
||||
loadFolder,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
mutateEntry,
|
||||
openDraft,
|
||||
openMessage,
|
||||
refreshCounts,
|
||||
register,
|
||||
saveDraft,
|
||||
search,
|
||||
send,
|
||||
setCounts(nextCounts: MailCounts) {
|
||||
counts.value = nextCounts
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -136,6 +136,69 @@ const defaultLocales: LocaleTree = {
|
||||
ringing: 'Timer',
|
||||
},
|
||||
},
|
||||
mail: {
|
||||
name: 'Mail',
|
||||
login: 'Sign In',
|
||||
register: 'Create Account',
|
||||
registerLink: 'Register',
|
||||
loginTitle: 'iFruit Mail',
|
||||
loginBody: 'Sign in to use your shared iFruit mailbox.',
|
||||
registerBody: 'Choose your new @ifruit.com address.',
|
||||
localPart: 'Email address',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordWarning:
|
||||
'Use an in-character password. Do not reuse a real-world password.',
|
||||
mailboxes: 'Mailboxes',
|
||||
inbox: 'Inbox',
|
||||
sent: 'Sent',
|
||||
drafts: 'Drafts',
|
||||
trash: 'Trash',
|
||||
compose: 'New Message',
|
||||
recipients: 'To',
|
||||
recipientHint: 'Separate up to 10 addresses with commas.',
|
||||
subject: 'Subject',
|
||||
body: 'Message',
|
||||
search: 'Search mail',
|
||||
noMail: 'No Mail',
|
||||
noMailBody: 'Messages in this mailbox will appear here.',
|
||||
noResults: 'No Results',
|
||||
noResultsBody: 'Try a different search.',
|
||||
untitled: '(no subject)',
|
||||
loadMore: 'Load More',
|
||||
logout: 'Sign Out',
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
reply: 'Reply',
|
||||
replyAll: 'Reply All',
|
||||
forward: 'Forward',
|
||||
markUnread: 'Mark as Unread',
|
||||
moveToTrash: 'Move to Trash',
|
||||
restore: 'Restore',
|
||||
deleteForever: 'Delete Forever',
|
||||
emptyTrash: 'Empty Trash',
|
||||
emptyTrashTitle: 'Empty Trash?',
|
||||
emptyTrashBody: 'Every message in Trash will be permanently deleted.',
|
||||
deleteDraft: 'Delete Draft',
|
||||
sentSuccess: 'Message sent.',
|
||||
newMessage: 'New mail from {sender}',
|
||||
passwordsMismatch: 'Passwords do not match.',
|
||||
errors: {
|
||||
invalid_email: 'Choose a valid 3–32 character iFruit address.',
|
||||
invalid_password: 'Password must be 6–64 characters.',
|
||||
invalid_credentials: 'Email or password is incorrect.',
|
||||
email_taken: 'That iFruit address is already registered.',
|
||||
rate_limited: 'Too many attempts. Try again in a minute.',
|
||||
invalid_message: 'Add a valid recipient and a subject or message.',
|
||||
recipient_not_found: 'One or more recipients do not exist.',
|
||||
invalid_draft: 'This draft contains invalid content.',
|
||||
not_authenticated: 'Your mail session has ended. Sign in again.',
|
||||
request_failed: 'Mail is temporarily unavailable.',
|
||||
invalid_request: 'The mail request was invalid.',
|
||||
default: 'The mail request failed.',
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
name: 'Notes',
|
||||
note: 'Note',
|
||||
@@ -157,8 +220,6 @@ const defaultLocales: LocaleTree = {
|
||||
pin: 'Pin note',
|
||||
unpin: 'Unpin note',
|
||||
deleteNote: 'Delete note',
|
||||
deleteTitle: 'Delete Note?',
|
||||
deleteBody: 'This note will be permanently deleted.',
|
||||
},
|
||||
photos: {
|
||||
name: 'Photos',
|
||||
@@ -275,6 +336,7 @@ const defaultLocales: LocaleTree = {
|
||||
add: 'Add',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
back: 'Back',
|
||||
delete: 'Delete',
|
||||
done: 'Done',
|
||||
edit: 'Edit',
|
||||
@@ -283,8 +345,10 @@ const defaultLocales: LocaleTree = {
|
||||
phone: 'Phone',
|
||||
phoneStatus: 'Phone status',
|
||||
reset: 'Reset',
|
||||
loading: 'Loading',
|
||||
search: 'Search',
|
||||
save: 'Save',
|
||||
send: 'Send',
|
||||
start: 'Start',
|
||||
stop: 'Stop',
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ export type PhoneAppId =
|
||||
| 'calculator'
|
||||
| 'camera'
|
||||
| 'clock'
|
||||
| 'mail'
|
||||
| 'notes'
|
||||
| 'photos'
|
||||
| 'app-store'
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export type MailFolder = 'inbox' | 'sent' | 'drafts' | 'trash'
|
||||
|
||||
export type MailCounts = {
|
||||
drafts: number
|
||||
inbox: number
|
||||
sent: number
|
||||
trash: number
|
||||
unread: number
|
||||
}
|
||||
|
||||
export type MailListItem = {
|
||||
body?: string
|
||||
created_at: string
|
||||
folder?: 'inbox' | 'sent'
|
||||
id: number | string
|
||||
is_read?: boolean
|
||||
message_id?: string
|
||||
preview: string
|
||||
recipients: string[]
|
||||
sender?: string
|
||||
subject: string
|
||||
trashed_at?: string | null
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export type MailMessage = MailListItem & {
|
||||
body: string
|
||||
folder: 'inbox' | 'sent'
|
||||
id: number
|
||||
message_id: string
|
||||
sender: string
|
||||
}
|
||||
|
||||
export type MailDraft = {
|
||||
body: string
|
||||
created_at: string
|
||||
id: string
|
||||
recipients: string[]
|
||||
subject: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type MailSession = {
|
||||
counts: MailCounts
|
||||
email: string
|
||||
}
|
||||
|
||||
export type MailListResponse = {
|
||||
hasMore: boolean
|
||||
items: MailListItem[]
|
||||
offset: number
|
||||
}
|
||||
|
||||
export type MailComposeDraft = {
|
||||
body: string
|
||||
id?: string
|
||||
recipients: string[]
|
||||
subject: string
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { MailMessage } from '@/types/mail'
|
||||
import {
|
||||
buildForwardDraft,
|
||||
buildReplyDraft,
|
||||
normalizeMailAddress,
|
||||
parseMailRecipients,
|
||||
} from '@/utils/mail'
|
||||
|
||||
const message: MailMessage = {
|
||||
body: 'Meet at Legion Square.',
|
||||
created_at: '2026-08-04 10:00:00',
|
||||
folder: 'inbox',
|
||||
id: 1,
|
||||
is_read: true,
|
||||
message_id: '00000000-0000-0000-0000-000000000001',
|
||||
preview: 'Meet at Legion Square.',
|
||||
recipients: ['alex@ifruit.com', 'jamie@ifruit.com'],
|
||||
sender: 'morgan@ifruit.com',
|
||||
subject: 'Plans',
|
||||
}
|
||||
|
||||
describe('mail addresses', () => {
|
||||
it('normalizes local parts and the iFruit domain', () => {
|
||||
expect(normalizeMailAddress(' Sky.User ')).toBe('sky.user@ifruit.com')
|
||||
expect(normalizeMailAddress('sky.user@ifruit.com')).toBe(
|
||||
'sky.user@ifruit.com',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid addresses and deduplicates recipients', () => {
|
||||
expect(normalizeMailAddress('ab')).toBeNull()
|
||||
expect(normalizeMailAddress('.user')).toBeNull()
|
||||
expect(normalizeMailAddress('user@example.com')).toBeNull()
|
||||
expect(parseMailRecipients('Alex, alex@ifruit.com; Jamie')).toEqual([
|
||||
'alex@ifruit.com',
|
||||
'jamie@ifruit.com',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mail compose helpers', () => {
|
||||
it('builds reply and reply-all drafts without the current account', () => {
|
||||
expect(buildReplyDraft(message, 'alex@ifruit.com').recipients).toEqual([
|
||||
'morgan@ifruit.com',
|
||||
])
|
||||
expect(
|
||||
buildReplyDraft(message, 'alex@ifruit.com', true).recipients,
|
||||
).toEqual(['morgan@ifruit.com', 'jamie@ifruit.com'])
|
||||
})
|
||||
|
||||
it('builds forward content and avoids duplicate subject prefixes', () => {
|
||||
const forwarded = buildForwardDraft(message)
|
||||
expect(forwarded.recipients).toEqual([])
|
||||
expect(forwarded.subject).toBe('Fwd: Plans')
|
||||
expect(forwarded.body).toContain('From: morgan@ifruit.com')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { MailComposeDraft, MailMessage } from '@/types/mail'
|
||||
|
||||
export const MAIL_DOMAIN = 'ifruit.com'
|
||||
export const MAIL_MAX_RECIPIENTS = 10
|
||||
|
||||
export function normalizeMailAddress(value: string): string | null {
|
||||
const normalized = value.trim().toLocaleLowerCase('en-US')
|
||||
const localPart = normalized.includes('@')
|
||||
? normalized.endsWith(`@${MAIL_DOMAIN}`)
|
||||
? normalized.slice(0, -MAIL_DOMAIN.length - 1)
|
||||
: ''
|
||||
: normalized
|
||||
|
||||
if (
|
||||
localPart.length < 3 ||
|
||||
localPart.length > 32 ||
|
||||
!/^[a-z0-9][a-z0-9._-]*[a-z0-9]$/.test(localPart) ||
|
||||
localPart.includes('..')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `${localPart}@${MAIL_DOMAIN}`
|
||||
}
|
||||
|
||||
export function parseMailRecipients(value: string): string[] | null {
|
||||
const parts = value
|
||||
.split(/[;,]/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
if (!parts.length || parts.length > MAIL_MAX_RECIPIENTS) return null
|
||||
|
||||
const recipients: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const part of parts) {
|
||||
const email = normalizeMailAddress(part)
|
||||
if (!email) return null
|
||||
if (!seen.has(email)) {
|
||||
seen.add(email)
|
||||
recipients.push(email)
|
||||
}
|
||||
}
|
||||
return recipients
|
||||
}
|
||||
|
||||
function prefixedSubject(prefix: 'Re' | 'Fwd', subject: string): string {
|
||||
const clean = subject.trim()
|
||||
if (new RegExp(`^${prefix}:`, 'i').test(clean)) return clean
|
||||
return `${prefix}: ${clean || '(no subject)'}`
|
||||
}
|
||||
|
||||
export function buildReplyDraft(
|
||||
message: MailMessage,
|
||||
ownEmail: string,
|
||||
replyAll = false,
|
||||
): MailComposeDraft {
|
||||
const candidates = replyAll
|
||||
? [message.sender, ...message.recipients]
|
||||
: [message.sender]
|
||||
const recipients = [...new Set(candidates)].filter(
|
||||
(address) => address !== ownEmail,
|
||||
)
|
||||
|
||||
return {
|
||||
body: `\n--- ${message.sender} ---\n${message.body}`,
|
||||
recipients,
|
||||
subject: prefixedSubject('Re', message.subject),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildForwardDraft(message: MailMessage): MailComposeDraft {
|
||||
return {
|
||||
body: `\n--- Forwarded message ---\nFrom: ${message.sender}\nTo: ${message.recipients.join(', ')}\n\n${message.body}`,
|
||||
recipients: [],
|
||||
subject: prefixedSubject('Fwd', message.subject),
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,11 @@ export async function nuiCall<T = unknown>(
|
||||
endpoint: string,
|
||||
data: Record<string, unknown> = {},
|
||||
): Promise<NuiResponse<T>> {
|
||||
const developmentPort = new URLSearchParams(window.location.search).get(
|
||||
'apiPort',
|
||||
)
|
||||
const baseUrl = import.meta.env.DEV
|
||||
? 'http://localhost:3001/api'
|
||||
? `http://localhost:${developmentPort ?? '3001'}/api`
|
||||
: `https://${resourceName}`
|
||||
|
||||
try {
|
||||
|
||||
@@ -31,6 +31,10 @@ describe('preferences', () => {
|
||||
sounds: false,
|
||||
})
|
||||
expect(value.settings.notifications.clock.enabled).toBe(true)
|
||||
expect(value.settings.notifications.mail).toEqual({
|
||||
enabled: true,
|
||||
sounds: true,
|
||||
})
|
||||
expect(value.settings.phoneScale).toBe(110)
|
||||
expect(value.settings.wallpaper).toBe('ember')
|
||||
})
|
||||
|
||||
@@ -50,6 +50,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
calculator: { enabled: true, sounds: true },
|
||||
camera: { enabled: true, sounds: true },
|
||||
clock: { enabled: true, sounds: true },
|
||||
mail: { enabled: true, sounds: true },
|
||||
notes: { enabled: true, sounds: true },
|
||||
photos: { enabled: true, sounds: true },
|
||||
settings: { enabled: true, sounds: true },
|
||||
|
||||
@@ -0,0 +1,733 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
kBadge,
|
||||
kBlock,
|
||||
kBlockTitle,
|
||||
kButton,
|
||||
kDialog,
|
||||
kDialogButton,
|
||||
kLink,
|
||||
kList,
|
||||
kListButton,
|
||||
kListInput,
|
||||
kListItem,
|
||||
kNavbar,
|
||||
kNavbarBackLink,
|
||||
kPage,
|
||||
kPreloader,
|
||||
kSearchbar,
|
||||
kToast,
|
||||
} from 'konsta/vue'
|
||||
import {
|
||||
FileText,
|
||||
Inbox,
|
||||
KeyRound,
|
||||
Mail,
|
||||
Send,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
MailComposeDraft,
|
||||
MailCounts,
|
||||
MailFolder,
|
||||
MailListItem,
|
||||
MailMessage,
|
||||
} from '@/types/mail'
|
||||
import {
|
||||
buildForwardDraft,
|
||||
buildReplyDraft,
|
||||
parseMailRecipients,
|
||||
} from '@/utils/mail'
|
||||
|
||||
type AuthMode = 'login' | 'register'
|
||||
type MailScreen = 'folders' | 'list' | 'message' | 'compose'
|
||||
type MailEvent = {
|
||||
data?: {
|
||||
counts?: MailCounts
|
||||
sender?: string
|
||||
subject?: string
|
||||
}
|
||||
type?: 'mail:changed' | 'mail:new'
|
||||
}
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const mail = useMailStore()
|
||||
const notifications = useNotificationsStore()
|
||||
const authMode = ref<AuthMode>('login')
|
||||
const authEmail = ref('')
|
||||
const authPassword = ref('')
|
||||
const authConfirm = ref('')
|
||||
const submitting = ref(false)
|
||||
const screen = ref<MailScreen>('folders')
|
||||
const selectedMessage = ref<MailMessage | null>(null)
|
||||
const composeReturn = ref<Exclude<MailScreen, 'compose'>>('folders')
|
||||
const draftId = ref<string>()
|
||||
const recipientText = ref('')
|
||||
const subject = ref('')
|
||||
const body = ref('')
|
||||
const composeTouched = ref(false)
|
||||
const toastOpened = ref(false)
|
||||
const toastText = ref('')
|
||||
const emptyTrashOpened = ref(false)
|
||||
let draftTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let toastTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const authenticated = computed(() => Boolean(mail.accountEmail))
|
||||
const folderTitle = computed(() => phone.t(`Apps.mail.${mail.folder}`))
|
||||
|
||||
function showToast(message: string): void {
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
toastText.value = message
|
||||
toastOpened.value = true
|
||||
toastTimer = setTimeout(() => {
|
||||
toastOpened.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function errorText(error?: string): string {
|
||||
const known = [
|
||||
'invalid_email',
|
||||
'invalid_password',
|
||||
'invalid_credentials',
|
||||
'email_taken',
|
||||
'rate_limited',
|
||||
'invalid_message',
|
||||
'recipient_not_found',
|
||||
'invalid_draft',
|
||||
'not_authenticated',
|
||||
'request_failed',
|
||||
'invalid_request',
|
||||
]
|
||||
return phone.t(
|
||||
`Apps.mail.errors.${error && known.includes(error) ? error : 'default'}`,
|
||||
)
|
||||
}
|
||||
|
||||
function eventValue(event: Event): string {
|
||||
return (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value.replace(' ', 'T'))
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat(phone.lang, {
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
month: 'short',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function itemTitle(item: MailListItem): string {
|
||||
if (mail.folder === 'drafts') {
|
||||
return item.recipients.join(', ') || phone.t('Apps.mail.compose')
|
||||
}
|
||||
if (item.folder === 'sent') return item.recipients.join(', ')
|
||||
return item.sender ?? ''
|
||||
}
|
||||
|
||||
function itemSubtitle(item: MailListItem): string {
|
||||
const title = item.subject || phone.t('Apps.mail.untitled')
|
||||
return `${formatDate(item.created_at)} · ${title} · ${item.preview || ''}`
|
||||
}
|
||||
|
||||
function folderCount(folder: MailFolder): number {
|
||||
return mail.counts[folder]
|
||||
}
|
||||
|
||||
async function submitAuth(): Promise<void> {
|
||||
if (
|
||||
authMode.value === 'register' &&
|
||||
authPassword.value !== authConfirm.value
|
||||
) {
|
||||
showToast(phone.t('Apps.mail.passwordsMismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
const response =
|
||||
authMode.value === 'login'
|
||||
? await mail.login(authEmail.value, authPassword.value)
|
||||
: await mail.register(authEmail.value, authPassword.value)
|
||||
submitting.value = false
|
||||
if (!response.success) {
|
||||
showToast(errorText(response.error))
|
||||
return
|
||||
}
|
||||
|
||||
authPassword.value = ''
|
||||
authConfirm.value = ''
|
||||
screen.value = 'folders'
|
||||
}
|
||||
|
||||
async function signOut(): Promise<void> {
|
||||
await mail.logout()
|
||||
selectedMessage.value = null
|
||||
screen.value = 'folders'
|
||||
}
|
||||
|
||||
async function openFolder(folder: MailFolder): Promise<void> {
|
||||
if (!(await mail.loadFolder(folder))) {
|
||||
showToast(errorText())
|
||||
return
|
||||
}
|
||||
screen.value = 'list'
|
||||
}
|
||||
|
||||
async function openItem(item: MailListItem): Promise<void> {
|
||||
if (mail.folder === 'drafts') {
|
||||
const draft = await mail.openDraft(String(item.id))
|
||||
if (!draft) {
|
||||
showToast(errorText('invalid_draft'))
|
||||
return
|
||||
}
|
||||
beginCompose({
|
||||
body: draft.body,
|
||||
id: draft.id,
|
||||
recipients: draft.recipients,
|
||||
subject: draft.subject,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const message = await mail.openMessage(Number(item.id))
|
||||
if (!message) {
|
||||
showToast(errorText())
|
||||
return
|
||||
}
|
||||
selectedMessage.value = message
|
||||
screen.value = 'message'
|
||||
}
|
||||
|
||||
function draftRecipientValues(): string[] {
|
||||
return recipientText.value
|
||||
.split(/[;,]/)
|
||||
.map((recipient) => recipient.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 10)
|
||||
}
|
||||
|
||||
async function saveDraftNow(): Promise<void> {
|
||||
if (!authenticated.value || screen.value !== 'compose') return
|
||||
if (
|
||||
!recipientText.value.trim() &&
|
||||
!subject.value.trim() &&
|
||||
!body.value.trim()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = await mail.saveDraft({
|
||||
body: body.value,
|
||||
id: draftId.value,
|
||||
recipients: draftRecipientValues(),
|
||||
subject: subject.value,
|
||||
})
|
||||
if (id) draftId.value = id
|
||||
}
|
||||
|
||||
function scheduleDraftSave(): void {
|
||||
if (!composeTouched.value || screen.value !== 'compose') return
|
||||
if (draftTimer) clearTimeout(draftTimer)
|
||||
draftTimer = setTimeout(() => void saveDraftNow(), 750)
|
||||
}
|
||||
|
||||
function beginCompose(draft?: MailComposeDraft): void {
|
||||
composeReturn.value = screen.value === 'compose' ? 'folders' : screen.value
|
||||
draftId.value = draft?.id
|
||||
recipientText.value = draft?.recipients.join(', ') ?? ''
|
||||
subject.value = draft?.subject ?? ''
|
||||
body.value = draft?.body ?? ''
|
||||
composeTouched.value = false
|
||||
screen.value = 'compose'
|
||||
}
|
||||
|
||||
async function closeCompose(): Promise<void> {
|
||||
await saveDraftNow()
|
||||
screen.value = composeReturn.value
|
||||
}
|
||||
|
||||
async function deleteCurrentDraft(): Promise<void> {
|
||||
if (draftId.value) await mail.deleteDraft(draftId.value)
|
||||
screen.value = composeReturn.value
|
||||
}
|
||||
|
||||
async function sendMessage(): Promise<void> {
|
||||
const recipients = parseMailRecipients(recipientText.value)
|
||||
if (!recipients) {
|
||||
showToast(errorText('invalid_message'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
const response = await mail.send({
|
||||
body: body.value,
|
||||
id: draftId.value,
|
||||
recipients,
|
||||
subject: subject.value,
|
||||
})
|
||||
submitting.value = false
|
||||
if (!response.success) {
|
||||
showToast(errorText(response.error))
|
||||
return
|
||||
}
|
||||
|
||||
showToast(phone.t('Apps.mail.sentSuccess'))
|
||||
selectedMessage.value = null
|
||||
screen.value = 'folders'
|
||||
}
|
||||
|
||||
function composeReply(replyAll = false): void {
|
||||
if (!selectedMessage.value) return
|
||||
beginCompose(
|
||||
buildReplyDraft(selectedMessage.value, mail.accountEmail, replyAll),
|
||||
)
|
||||
}
|
||||
|
||||
function composeForward(): void {
|
||||
if (selectedMessage.value)
|
||||
beginCompose(buildForwardDraft(selectedMessage.value))
|
||||
}
|
||||
|
||||
async function mutateSelected(
|
||||
endpoint: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
): Promise<void> {
|
||||
if (!selectedMessage.value) return
|
||||
if (!(await mail.mutateEntry(endpoint, selectedMessage.value.id, extra))) {
|
||||
showToast(errorText())
|
||||
return
|
||||
}
|
||||
selectedMessage.value = null
|
||||
screen.value = 'list'
|
||||
}
|
||||
|
||||
function updateSearch(event: Event): void {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
void mail.loadFolder(mail.folder, value)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function confirmEmptyTrash(): Promise<void> {
|
||||
emptyTrashOpened.value = false
|
||||
if (!(await mail.emptyTrash())) showToast(errorText())
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
if (screen.value === 'list') screen.value = 'folders'
|
||||
else if (screen.value === 'message') screen.value = 'list'
|
||||
}
|
||||
|
||||
function onMailEvent(event: MessageEvent<MailEvent>): void {
|
||||
if (event.data.type === 'mail:changed' && event.data.data?.counts) {
|
||||
mail.setCounts(event.data.data.counts)
|
||||
if (screen.value === 'list') {
|
||||
void mail.loadFolder(mail.folder, mail.search)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.data.type === 'mail:new' && event.data.data?.sender) {
|
||||
notifications.show({
|
||||
appId: 'mail',
|
||||
subtitle: event.data.data.subject || phone.t('Apps.mail.untitled'),
|
||||
text: phone.t('Apps.mail.newMessage', {
|
||||
sender: event.data.data.sender,
|
||||
}),
|
||||
title: phone.t('Apps.mail.name'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch([recipientText, subject, body], () => {
|
||||
composeTouched.value = true
|
||||
scheduleDraftSave()
|
||||
})
|
||||
|
||||
onMounted(() => window.addEventListener('message', onMailEvent))
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onMailEvent)
|
||||
if (draftTimer) clearTimeout(draftTimer)
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
if (screen.value === 'compose') {
|
||||
void saveDraftNow().finally(() => mail.logout())
|
||||
} else {
|
||||
void mail.logout()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-page
|
||||
v-if="!authenticated"
|
||||
class="!pt-[44px] !pb-[25px]"
|
||||
:aria-label="phone.t('Apps.mail.loginTitle')"
|
||||
>
|
||||
<k-navbar large transparent :title="phone.t('Apps.mail.name')">
|
||||
<template #right>
|
||||
<k-link
|
||||
component="button"
|
||||
@click="authMode = authMode === 'login' ? 'register' : 'login'"
|
||||
>
|
||||
{{
|
||||
phone.t(
|
||||
authMode === 'login'
|
||||
? 'Apps.mail.registerLink'
|
||||
: 'Apps.mail.login',
|
||||
)
|
||||
}}
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
<k-block-title>
|
||||
{{
|
||||
phone.t(
|
||||
authMode === 'login'
|
||||
? 'Apps.mail.loginBody'
|
||||
: 'Apps.mail.passwordWarning',
|
||||
)
|
||||
}}
|
||||
</k-block-title>
|
||||
<k-list>
|
||||
<k-list-input
|
||||
class="relative"
|
||||
:value="authEmail"
|
||||
:label="
|
||||
phone.t(
|
||||
authMode === 'login' ? 'Apps.mail.email' : 'Apps.mail.localPart',
|
||||
)
|
||||
"
|
||||
outline
|
||||
floating-label
|
||||
:input-class="authMode === 'register' ? 'pr-20' : undefined"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
:clear-button="authMode === 'login'"
|
||||
@input="authEmail = eventValue($event)"
|
||||
@clear="authEmail = ''"
|
||||
>
|
||||
<span
|
||||
v-if="authMode === 'register'"
|
||||
class="pointer-events-none absolute right-8 top-1/2 -translate-y-1/2 text-sm opacity-50"
|
||||
>
|
||||
@ifruit.com
|
||||
</span>
|
||||
</k-list-input>
|
||||
<k-list-input
|
||||
type="password"
|
||||
:value="authPassword"
|
||||
:label="phone.t('Apps.mail.password')"
|
||||
outline
|
||||
floating-label
|
||||
autocomplete="current-password"
|
||||
@input="authPassword = eventValue($event)"
|
||||
>
|
||||
<template #media><KeyRound :size="20" /></template>
|
||||
</k-list-input>
|
||||
<k-list-input
|
||||
v-if="authMode === 'register'"
|
||||
type="password"
|
||||
:value="authConfirm"
|
||||
:label="phone.t('Apps.mail.confirmPassword')"
|
||||
outline
|
||||
floating-label
|
||||
autocomplete="new-password"
|
||||
@input="authConfirm = eventValue($event)"
|
||||
>
|
||||
<template #media><KeyRound :size="20" /></template>
|
||||
</k-list-input>
|
||||
</k-list>
|
||||
<k-block>
|
||||
<k-button large rounded :disabled="submitting" @click="submitAuth">
|
||||
<k-preloader v-if="submitting" />
|
||||
<template v-else>
|
||||
{{
|
||||
phone.t(
|
||||
authMode === 'login' ? 'Apps.mail.login' : 'Apps.mail.register',
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</k-button>
|
||||
</k-block>
|
||||
</k-page>
|
||||
|
||||
<k-page
|
||||
v-else-if="screen === 'folders'"
|
||||
class="!pt-[44px] !pb-[25px]"
|
||||
:aria-label="phone.t('Apps.mail.mailboxes')"
|
||||
>
|
||||
<k-navbar large transparent :title="phone.t('Apps.mail.mailboxes')">
|
||||
<template #left>
|
||||
<k-link component="button" @click="signOut">
|
||||
{{ phone.t('Apps.mail.logout') }}
|
||||
</k-link>
|
||||
</template>
|
||||
<template #right>
|
||||
<k-link
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Apps.mail.compose')"
|
||||
@click="beginCompose()"
|
||||
>
|
||||
<SquarePen :size="21" />
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
<k-block-title>{{ mail.accountEmail }}</k-block-title>
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
link
|
||||
:title="phone.t('Apps.mail.inbox')"
|
||||
@click="openFolder('inbox')"
|
||||
>
|
||||
<template #media><Inbox :size="20" /></template>
|
||||
<template #after>
|
||||
<k-badge v-if="mail.counts.unread">{{ mail.counts.unread }}</k-badge>
|
||||
<template v-else>{{ folderCount('inbox') }}</template>
|
||||
</template>
|
||||
</k-list-item>
|
||||
<k-list-item
|
||||
link
|
||||
:title="phone.t('Apps.mail.sent')"
|
||||
@click="openFolder('sent')"
|
||||
>
|
||||
<template #media><Send :size="20" /></template>
|
||||
<template #after>{{ folderCount('sent') }}</template>
|
||||
</k-list-item>
|
||||
<k-list-item
|
||||
link
|
||||
:title="phone.t('Apps.mail.drafts')"
|
||||
@click="openFolder('drafts')"
|
||||
>
|
||||
<template #media><FileText :size="20" /></template>
|
||||
<template #after>{{ folderCount('drafts') }}</template>
|
||||
</k-list-item>
|
||||
<k-list-item
|
||||
link
|
||||
:title="phone.t('Apps.mail.trash')"
|
||||
@click="openFolder('trash')"
|
||||
>
|
||||
<template #media><Trash2 :size="20" /></template>
|
||||
<template #after>{{ folderCount('trash') }}</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
</k-page>
|
||||
|
||||
<k-page
|
||||
v-else-if="screen === 'list'"
|
||||
class="!pt-[44px] !pb-[25px]"
|
||||
:aria-label="folderTitle"
|
||||
>
|
||||
<k-navbar :title="folderTitle">
|
||||
<template #left>
|
||||
<k-navbar-back-link
|
||||
component="button"
|
||||
:text="phone.t('Apps.mail.mailboxes')"
|
||||
:aria-label="phone.t('Apps.mail.mailboxes')"
|
||||
@click="goBack"
|
||||
/>
|
||||
</template>
|
||||
<template #right>
|
||||
<k-link
|
||||
v-if="mail.folder === 'trash' && mail.items.length"
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Apps.mail.emptyTrash')"
|
||||
@click="emptyTrashOpened = true"
|
||||
>
|
||||
<Trash2 :size="20" />
|
||||
</k-link>
|
||||
<k-link
|
||||
v-else
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Apps.mail.compose')"
|
||||
@click="beginCompose()"
|
||||
>
|
||||
<SquarePen :size="21" />
|
||||
</k-link>
|
||||
</template>
|
||||
<template #subnavbar>
|
||||
<k-searchbar
|
||||
:value="mail.search"
|
||||
:placeholder="phone.t('Apps.mail.search')"
|
||||
@input="updateSearch"
|
||||
@clear="mail.loadFolder(mail.folder, '')"
|
||||
/>
|
||||
</template>
|
||||
</k-navbar>
|
||||
|
||||
<k-block v-if="mail.loading"><k-preloader /></k-block>
|
||||
<k-list v-else-if="mail.items.length" strong>
|
||||
<k-list-item
|
||||
v-for="item in mail.items"
|
||||
:key="`${mail.folder}-${item.id}`"
|
||||
link
|
||||
:title="itemTitle(item)"
|
||||
:subtitle="itemSubtitle(item)"
|
||||
:strong-title="mail.folder === 'inbox' && !item.is_read"
|
||||
@click="openItem(item)"
|
||||
>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
<template v-else>
|
||||
<k-block-title large>
|
||||
{{ phone.t(mail.search ? 'Apps.mail.noResults' : 'Apps.mail.noMail') }}
|
||||
</k-block-title>
|
||||
<k-block strong inset>
|
||||
{{
|
||||
phone.t(
|
||||
mail.search ? 'Apps.mail.noResultsBody' : 'Apps.mail.noMailBody',
|
||||
)
|
||||
}}
|
||||
</k-block>
|
||||
</template>
|
||||
<k-list v-if="mail.hasMore" inset>
|
||||
<k-list-button @click="mail.loadFolder(mail.folder, mail.search, true)">
|
||||
{{ phone.t('Apps.mail.loadMore') }}
|
||||
</k-list-button>
|
||||
</k-list>
|
||||
</k-page>
|
||||
|
||||
<k-page
|
||||
v-else-if="screen === 'message' && selectedMessage"
|
||||
class="!pt-[44px] !pb-[25px]"
|
||||
>
|
||||
<k-navbar :title="phone.t('Apps.mail.name')">
|
||||
<template #left>
|
||||
<k-navbar-back-link
|
||||
component="button"
|
||||
:text="folderTitle"
|
||||
:aria-label="folderTitle"
|
||||
@click="goBack"
|
||||
/>
|
||||
</template>
|
||||
</k-navbar>
|
||||
<k-list strong>
|
||||
<k-list-item
|
||||
:title="selectedMessage.sender"
|
||||
:subtitle="`${formatDate(selectedMessage.created_at)} · ${phone.t('Apps.mail.to')}: ${selectedMessage.recipients.join(', ')}`"
|
||||
>
|
||||
<template #media><Mail :size="20" /></template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
<k-block-title>
|
||||
{{ selectedMessage.subject || phone.t('Apps.mail.untitled') }}
|
||||
</k-block-title>
|
||||
<k-block strong inset>
|
||||
<span class="whitespace-pre-wrap">{{ selectedMessage.body }}</span>
|
||||
</k-block>
|
||||
<k-list strong inset>
|
||||
<k-list-button @click="composeReply(false)">{{
|
||||
phone.t('Apps.mail.reply')
|
||||
}}</k-list-button>
|
||||
<k-list-button @click="composeReply(true)">{{
|
||||
phone.t('Apps.mail.replyAll')
|
||||
}}</k-list-button>
|
||||
<k-list-button @click="composeForward">{{
|
||||
phone.t('Apps.mail.forward')
|
||||
}}</k-list-button>
|
||||
<k-list-button
|
||||
v-if="!selectedMessage.trashed_at"
|
||||
@click="mutateSelected('mail:set-read', { read: false })"
|
||||
>
|
||||
{{ phone.t('Apps.mail.markUnread') }}
|
||||
</k-list-button>
|
||||
<k-list-button
|
||||
v-if="selectedMessage.trashed_at"
|
||||
@click="mutateSelected('mail:restore')"
|
||||
>
|
||||
{{ phone.t('Apps.mail.restore') }}
|
||||
</k-list-button>
|
||||
<k-list-button
|
||||
v-if="selectedMessage.trashed_at"
|
||||
@click="mutateSelected('mail:delete-forever')"
|
||||
>
|
||||
{{ phone.t('Apps.mail.deleteForever') }}
|
||||
</k-list-button>
|
||||
<k-list-button v-else @click="mutateSelected('mail:trash')">
|
||||
{{ phone.t('Apps.mail.moveToTrash') }}
|
||||
</k-list-button>
|
||||
</k-list>
|
||||
</k-page>
|
||||
|
||||
<k-page v-else-if="screen === 'compose'" class="!pt-[44px] !pb-[25px]">
|
||||
<k-navbar>
|
||||
<template #left>
|
||||
<k-link component="button" @click="closeCompose">
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</k-link>
|
||||
</template>
|
||||
<template #right>
|
||||
<k-link component="button" :disabled="submitting" @click="sendMessage">
|
||||
{{ phone.t('Common.send') }}
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
<k-block-title large>{{ phone.t('Apps.mail.compose') }}</k-block-title>
|
||||
<k-list strong>
|
||||
<k-list-input
|
||||
:value="recipientText"
|
||||
:label="phone.t('Apps.mail.recipients')"
|
||||
:info="phone.t('Apps.mail.recipientHint')"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
@input="recipientText = eventValue($event)"
|
||||
/>
|
||||
<k-list-input
|
||||
:value="subject"
|
||||
:label="phone.t('Apps.mail.subject')"
|
||||
maxlength="120"
|
||||
@input="subject = eventValue($event)"
|
||||
/>
|
||||
<k-list-input
|
||||
type="textarea"
|
||||
:value="body"
|
||||
:label="phone.t('Apps.mail.body')"
|
||||
maxlength="20000"
|
||||
@input="body = eventValue($event)"
|
||||
/>
|
||||
</k-list>
|
||||
<k-list v-if="draftId" strong inset>
|
||||
<k-list-button @click="deleteCurrentDraft">
|
||||
{{ phone.t('Apps.mail.deleteDraft') }}
|
||||
</k-list-button>
|
||||
</k-list>
|
||||
<k-block v-if="submitting"><k-preloader /></k-block>
|
||||
</k-page>
|
||||
|
||||
<k-dialog
|
||||
:opened="emptyTrashOpened"
|
||||
:title="phone.t('Apps.mail.emptyTrashTitle')"
|
||||
:content="phone.t('Apps.mail.emptyTrashBody')"
|
||||
@backdropclick="emptyTrashOpened = false"
|
||||
>
|
||||
<template #buttons>
|
||||
<k-dialog-button @click="emptyTrashOpened = false">
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</k-dialog-button>
|
||||
<k-dialog-button strong @click="confirmEmptyTrash">
|
||||
{{ phone.t('Apps.mail.emptyTrash') }}
|
||||
</k-dialog-button>
|
||||
</template>
|
||||
</k-dialog>
|
||||
|
||||
<k-toast
|
||||
:opened="toastOpened"
|
||||
position="center"
|
||||
:text="toastText"
|
||||
@click="toastOpened = false"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,12 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
kActions,
|
||||
kActionsButton,
|
||||
kActionsGroup,
|
||||
kBlock,
|
||||
kBlockTitle,
|
||||
kDialog,
|
||||
kDialogButton,
|
||||
kLink,
|
||||
kList,
|
||||
kListButton,
|
||||
@@ -15,10 +10,17 @@ import {
|
||||
kNavbar,
|
||||
kNavbarBackLink,
|
||||
kPage,
|
||||
kPopover,
|
||||
kSearchbar,
|
||||
} from 'konsta/vue'
|
||||
import { Ellipsis, Pin, SquarePen } from 'lucide-vue-next'
|
||||
import { computed, type CSSProperties, ref } from 'vue'
|
||||
import { Ellipsis, Pin, PinOff, SquarePen, Trash2 } from 'lucide-vue-next'
|
||||
import {
|
||||
computed,
|
||||
type ComponentPublicInstance,
|
||||
type CSSProperties,
|
||||
nextTick,
|
||||
ref,
|
||||
} from 'vue'
|
||||
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
@@ -31,8 +33,20 @@ const editorId = ref<string | null>(null)
|
||||
const editorOpened = ref(false)
|
||||
const draftTitle = ref('')
|
||||
const draftBody = ref('')
|
||||
const deleteCandidate = ref<Note | null>(null)
|
||||
const menuButton = ref<ComponentPublicInstance | null>(null)
|
||||
const menuOpened = ref(false)
|
||||
const menuTarget = computed(
|
||||
() => menuButton.value?.$el as HTMLElement | undefined,
|
||||
)
|
||||
const menuTargetStyle = ref<CSSProperties>({})
|
||||
const pinActionColors = computed(() => ({
|
||||
textIos: phone.isDarkMode ? 'text-white' : 'text-black',
|
||||
textMaterial: phone.isDarkMode ? 'text-white' : 'text-black',
|
||||
}))
|
||||
const deleteActionColors = {
|
||||
textIos: 'text-red-500',
|
||||
textMaterial: 'text-red-500',
|
||||
}
|
||||
const noteBodyStyle: CSSProperties = {
|
||||
height: 'calc(100cqh - 210px)',
|
||||
resize: 'none',
|
||||
@@ -130,15 +144,32 @@ function saveAndClose(): void {
|
||||
editorOpened.value = false
|
||||
}
|
||||
|
||||
function openMenu(): void {
|
||||
if (persistDraft()) menuOpened.value = true
|
||||
async function openMenu(): Promise<void> {
|
||||
if (!persistDraft()) return
|
||||
|
||||
const target = menuTarget.value
|
||||
const screen = target?.closest('.phone-screen')
|
||||
if (target && screen) {
|
||||
const screenRect = screen.getBoundingClientRect()
|
||||
menuTargetStyle.value = {
|
||||
'--k-safe-area-left': `${Math.round(screenRect.left + 2)}px`,
|
||||
'--k-safe-area-right': `${Math.round(
|
||||
document.body.offsetWidth - screenRect.right + 2,
|
||||
)}px`,
|
||||
'--k-safe-area-top': `${Math.round(screenRect.top + 8)}px`,
|
||||
}
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
menuOpened.value = true
|
||||
}
|
||||
|
||||
function requestDelete(): void {
|
||||
function deleteNote(): void {
|
||||
const note = persistDraft()
|
||||
if (!note) return
|
||||
notes.deleteNote(note.id)
|
||||
menuOpened.value = false
|
||||
deleteCandidate.value = note
|
||||
editorOpened.value = false
|
||||
}
|
||||
|
||||
function togglePinned(): void {
|
||||
@@ -148,12 +179,6 @@ function togglePinned(): void {
|
||||
menuOpened.value = false
|
||||
}
|
||||
|
||||
function confirmDelete(): void {
|
||||
if (!deleteCandidate.value) return
|
||||
notes.deleteNote(deleteCandidate.value.id)
|
||||
deleteCandidate.value = null
|
||||
editorOpened.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -191,13 +216,12 @@ function confirmDelete(): void {
|
||||
<k-list-item
|
||||
v-for="note in visibleNotes"
|
||||
:key="note.id"
|
||||
link
|
||||
link-component="button"
|
||||
href="#"
|
||||
:title="noteTitle(note)"
|
||||
:subtitle="noteSubtitle(note)"
|
||||
:chevron="false"
|
||||
strong-title="auto"
|
||||
@click="editNote(note)"
|
||||
@click.prevent="editNote(note)"
|
||||
>
|
||||
<template v-if="note.pinned" #after>
|
||||
<Pin :size="15" aria-hidden="true" />
|
||||
@@ -234,8 +258,10 @@ function confirmDelete(): void {
|
||||
</template>
|
||||
<template #right>
|
||||
<k-link
|
||||
ref="menuButton"
|
||||
component="button"
|
||||
icon-only
|
||||
:style="menuTargetStyle"
|
||||
:aria-label="phone.t('Apps.notes.actions')"
|
||||
@click="openMenu"
|
||||
>
|
||||
@@ -265,41 +291,38 @@ function confirmDelete(): void {
|
||||
/>
|
||||
</k-list>
|
||||
|
||||
<k-actions
|
||||
:opened="menuOpened"
|
||||
@backdropclick="menuOpened = false"
|
||||
>
|
||||
<k-actions-group>
|
||||
<k-actions-button @click="togglePinned">
|
||||
{{
|
||||
phone.t(currentNote?.pinned ? 'Apps.notes.unpin' : 'Apps.notes.pin')
|
||||
}}
|
||||
</k-actions-button>
|
||||
<k-actions-button @click="requestDelete">
|
||||
{{ phone.t('Apps.notes.deleteNote') }}
|
||||
</k-actions-button>
|
||||
</k-actions-group>
|
||||
<k-actions-group>
|
||||
<k-actions-button bold @click="menuOpened = false">
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</k-actions-button>
|
||||
</k-actions-group>
|
||||
</k-actions>
|
||||
<Teleport to="body">
|
||||
<k-popover
|
||||
:opened="menuOpened"
|
||||
:target="menuTarget"
|
||||
:class="{ dark: phone.isDarkMode }"
|
||||
angle
|
||||
@backdropclick="menuOpened = false"
|
||||
>
|
||||
<k-list nested>
|
||||
<k-list-button
|
||||
link-component="button"
|
||||
:colors="pinActionColors"
|
||||
@click="togglePinned"
|
||||
>
|
||||
<PinOff v-if="currentNote?.pinned" :size="18" />
|
||||
<Pin v-else :size="18" />
|
||||
{{
|
||||
phone.t(
|
||||
currentNote?.pinned ? 'Apps.notes.unpin' : 'Apps.notes.pin',
|
||||
)
|
||||
}}
|
||||
</k-list-button>
|
||||
<k-list-button
|
||||
link-component="button"
|
||||
:colors="deleteActionColors"
|
||||
@click="deleteNote"
|
||||
>
|
||||
<Trash2 :size="18" />
|
||||
{{ phone.t('Apps.notes.deleteNote') }}
|
||||
</k-list-button>
|
||||
</k-list>
|
||||
</k-popover>
|
||||
</Teleport>
|
||||
</k-page>
|
||||
|
||||
<k-dialog
|
||||
:opened="Boolean(deleteCandidate)"
|
||||
:title="phone.t('Apps.notes.deleteTitle')"
|
||||
:content="phone.t('Apps.notes.deleteBody')"
|
||||
@backdropclick="deleteCandidate = null"
|
||||
>
|
||||
<template #buttons>
|
||||
<k-dialog-button @click="deleteCandidate = null">
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</k-dialog-button>
|
||||
<k-dialog-button strong @click="confirmDelete">
|
||||
{{ phone.t('Common.delete') }}
|
||||
</k-dialog-button>
|
||||
</template>
|
||||
</k-dialog>
|
||||
</template>
|
||||
|
||||
@@ -2,13 +2,197 @@ const cors = require('cors')
|
||||
const express = require('express')
|
||||
|
||||
const app = express()
|
||||
const port = 3001
|
||||
const port = Number(process.argv[2]) || 3001
|
||||
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
|
||||
let authenticated = false
|
||||
let draft = null
|
||||
const messages = [
|
||||
{
|
||||
body: 'Welcome to iFruit Mail. Your shared mailbox is ready to use.',
|
||||
created_at: '2026-08-04 11:30:00',
|
||||
folder: 'inbox',
|
||||
id: 1,
|
||||
is_read: false,
|
||||
message_id: 'b73f3872-54cc-4d74-a058-6caa24f0dfff',
|
||||
preview: 'Welcome to iFruit Mail. Your shared mailbox is ready to use.',
|
||||
recipients: ['demo@ifruit.com'],
|
||||
sender: 'support@ifruit.com',
|
||||
subject: 'Welcome to iFruit Mail',
|
||||
trashed_at: null,
|
||||
},
|
||||
{
|
||||
body: 'The meet is behind the casino at nine. Bring the blue car.',
|
||||
created_at: '2026-08-04 10:15:00',
|
||||
folder: 'inbox',
|
||||
id: 2,
|
||||
is_read: true,
|
||||
message_id: '79042d13-f86d-4418-a490-8b558539c20b',
|
||||
preview: 'The meet is behind the casino at nine. Bring the blue car.',
|
||||
recipients: ['demo@ifruit.com', 'jamie@ifruit.com'],
|
||||
sender: 'morgan@ifruit.com',
|
||||
subject: 'Tonight',
|
||||
trashed_at: null,
|
||||
},
|
||||
{
|
||||
body: 'I will be there.',
|
||||
created_at: '2026-08-03 18:05:00',
|
||||
folder: 'sent',
|
||||
id: 3,
|
||||
is_read: true,
|
||||
message_id: 'c4414c1e-5fe3-46ea-bc0b-b5b9785c91bd',
|
||||
preview: 'I will be there.',
|
||||
recipients: ['morgan@ifruit.com'],
|
||||
sender: 'demo@ifruit.com',
|
||||
subject: 'Re: Tonight',
|
||||
trashed_at: null,
|
||||
},
|
||||
{
|
||||
body: 'This is an old message.',
|
||||
created_at: '2026-08-02 09:20:00',
|
||||
folder: 'inbox',
|
||||
id: 4,
|
||||
is_read: true,
|
||||
message_id: 'd99669ab-c1ac-42d4-8ed4-3c4fe7fa37fb',
|
||||
preview: 'This is an old message.',
|
||||
recipients: ['demo@ifruit.com'],
|
||||
sender: 'news@ifruit.com',
|
||||
subject: 'Old mail',
|
||||
trashed_at: '2026-08-04 08:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
function counts() {
|
||||
return {
|
||||
drafts: draft ? 1 : 0,
|
||||
inbox: messages.filter(
|
||||
(item) => item.folder === 'inbox' && !item.trashed_at,
|
||||
).length,
|
||||
sent: messages.filter((item) => item.folder === 'sent' && !item.trashed_at)
|
||||
.length,
|
||||
trash: messages.filter((item) => item.trashed_at).length,
|
||||
unread: messages.filter(
|
||||
(item) => item.folder === 'inbox' && !item.trashed_at && !item.is_read,
|
||||
).length,
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/api/:endpoint', (request, response) => {
|
||||
console.log(`[NUI] ${request.params.endpoint}`, request.body)
|
||||
const endpoint = request.params.endpoint
|
||||
if (endpoint === 'mail:login' || endpoint === 'mail:register') {
|
||||
authenticated = true
|
||||
response.json({
|
||||
success: true,
|
||||
data: { counts: counts(), email: 'demo@ifruit.com' },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'mail:logout') {
|
||||
authenticated = false
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint.startsWith('mail:') && !authenticated) {
|
||||
response.json({ success: false, error: 'not_authenticated' })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'mail:counts') {
|
||||
response.json({ success: true, data: counts() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'mail:list') {
|
||||
const { folder, search = '' } = request.body
|
||||
let items =
|
||||
folder === 'drafts'
|
||||
? draft
|
||||
? [{ ...draft, created_at: draft.updated_at, preview: draft.body }]
|
||||
: []
|
||||
: messages.filter((item) =>
|
||||
folder === 'trash'
|
||||
? item.trashed_at
|
||||
: item.folder === folder && !item.trashed_at,
|
||||
)
|
||||
const query = String(search).toLowerCase()
|
||||
if (query) {
|
||||
items = items.filter((item) =>
|
||||
`${item.sender ?? ''} ${item.subject} ${item.preview}`
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
}
|
||||
response.json({ success: true, data: { hasMore: false, items, offset: 0 } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'mail:get') {
|
||||
const message = messages.find((item) => item.id === Number(request.body.id))
|
||||
if (message) message.is_read = true
|
||||
response.json(
|
||||
message
|
||||
? { success: true, data: message }
|
||||
: { success: false, error: 'message_not_found' },
|
||||
)
|
||||
return
|
||||
}
|
||||
if (endpoint === 'mail:get-draft') {
|
||||
response.json(
|
||||
draft
|
||||
? { success: true, data: draft }
|
||||
: { success: false, error: 'draft_not_found' },
|
||||
)
|
||||
return
|
||||
}
|
||||
if (endpoint === 'mail:save-draft') {
|
||||
draft = {
|
||||
...request.body,
|
||||
id: request.body.id ?? '748836d2-2308-4c21-a4d8-3af6df6dd7eb',
|
||||
created_at: '2026-08-04 11:40:00',
|
||||
updated_at: '2026-08-04 11:40:00',
|
||||
}
|
||||
response.json({ success: true, data: { id: draft.id } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'mail:delete-draft') {
|
||||
draft = null
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'mail:send') {
|
||||
draft = null
|
||||
response.json({ success: true, data: { id: 'mock-sent-message' } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'mail:trash' || endpoint === 'mail:restore') {
|
||||
const message = messages.find((item) => item.id === Number(request.body.id))
|
||||
if (message)
|
||||
message.trashed_at =
|
||||
endpoint === 'mail:trash' ? '2026-08-04 12:00:00' : null
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'mail:delete-forever') {
|
||||
const index = messages.findIndex(
|
||||
(item) => item.id === Number(request.body.id),
|
||||
)
|
||||
if (index >= 0) messages.splice(index, 1)
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'mail:empty-trash') {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index].trashed_at) messages.splice(index, 1)
|
||||
}
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'mail:set-read') {
|
||||
const message = messages.find((item) => item.id === Number(request.body.id))
|
||||
if (message) message.is_read = request.body.read
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
response.json({ success: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,2 +1,14 @@
|
||||
Config.Command = "phone"
|
||||
|
||||
Config.Mail = {
|
||||
Domain = "ifruit.com",
|
||||
LocalPartMinLength = 3,
|
||||
LocalPartMaxLength = 32,
|
||||
PasswordMinLength = 6,
|
||||
PasswordMaxLength = 64,
|
||||
SubjectMaxLength = 120,
|
||||
BodyMaxLength = 20000,
|
||||
MaxRecipients = 10,
|
||||
PageSize = 50,
|
||||
AuthAttemptsPerMinute = 5,
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ Locales["en"] = {
|
||||
CommandDescription = "Open your phone.",
|
||||
Nui = {
|
||||
Common = {
|
||||
add = "Add", cancel = "Cancel", close = "Close", delete = "Delete", done = "Done", edit = "Edit", home = "Home", pause = "Pause",
|
||||
add = "Add", back = "Back", cancel = "Cancel", close = "Close", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause",
|
||||
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
|
||||
save = "Save", search = "Search", start = "Start", stop = "Stop",
|
||||
save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop",
|
||||
},
|
||||
Notifications = { now = "now" },
|
||||
Home = {
|
||||
@@ -42,6 +42,33 @@ Locales["en"] = {
|
||||
note = "Note", notePlaceholder = "Timer", sound = "Sound", ringing = "Timer",
|
||||
},
|
||||
},
|
||||
mail = {
|
||||
name = "Mail", login = "Sign In", register = "Create Account", registerLink = "Register", loginTitle = "iFruit Mail",
|
||||
loginBody = "Sign in to use your shared iFruit mailbox.", registerBody = "Choose your new @ifruit.com address.",
|
||||
localPart = "Email address", email = "Email", password = "Password", confirmPassword = "Confirm password",
|
||||
passwordWarning = "Use an in-character password. Do not reuse a real-world password.",
|
||||
mailboxes = "Mailboxes", inbox = "Inbox", sent = "Sent", drafts = "Drafts", trash = "Trash",
|
||||
compose = "New Message", recipients = "To", recipientHint = "Separate up to 10 addresses with commas.",
|
||||
subject = "Subject", body = "Message", search = "Search mail", noMail = "No Mail",
|
||||
noMailBody = "Messages in this mailbox will appear here.", noResults = "No Results",
|
||||
noResultsBody = "Try a different search.", untitled = "(no subject)", loadMore = "Load More",
|
||||
logout = "Sign Out", from = "From", to = "To", reply = "Reply", replyAll = "Reply All", forward = "Forward",
|
||||
markUnread = "Mark as Unread", moveToTrash = "Move to Trash", restore = "Restore",
|
||||
deleteForever = "Delete Forever", emptyTrash = "Empty Trash", emptyTrashTitle = "Empty Trash?",
|
||||
emptyTrashBody = "Every message in Trash will be permanently deleted.", deleteDraft = "Delete Draft",
|
||||
sentSuccess = "Message sent.", newMessage = "New mail from {sender}", passwordsMismatch = "Passwords do not match.",
|
||||
errors = {
|
||||
invalid_email = "Choose a valid 3–32 character iFruit address.",
|
||||
invalid_password = "Password must be 6–64 characters.",
|
||||
invalid_credentials = "Email or password is incorrect.", email_taken = "That iFruit address is already registered.",
|
||||
rate_limited = "Too many attempts. Try again in a minute.",
|
||||
invalid_message = "Add a valid recipient and a subject or message.",
|
||||
recipient_not_found = "One or more recipients do not exist.", invalid_draft = "This draft contains invalid content.",
|
||||
not_authenticated = "Your mail session has ended. Sign in again.",
|
||||
request_failed = "Mail is temporarily unavailable.", invalid_request = "The mail request was invalid.",
|
||||
default = "The mail request failed.",
|
||||
},
|
||||
},
|
||||
notes = {
|
||||
name = "Notes", note = "Note", back = "Back", actions = "Note actions",
|
||||
newNote = "New Note", searchPlaceholder = "Search Notes",
|
||||
@@ -49,7 +76,7 @@ Locales["en"] = {
|
||||
untitled = "Untitled", noText = "No additional text", emptyBadge = "ON DEVICE", emptyTitle = "No Notes",
|
||||
emptyBody = "Create a note to keep important details close at hand.", noResults = "No Results",
|
||||
noResultsBody = "Try searching for a different word or phrase.", pin = "Pin note", unpin = "Unpin note",
|
||||
deleteNote = "Delete note", deleteTitle = "Delete Note?", deleteBody = "This note will be permanently deleted.",
|
||||
deleteNote = "Delete note",
|
||||
},
|
||||
photos = {
|
||||
name = "Photos", searchPlaceholder = "Photos, people, places...", recents = "Recents",
|
||||
|
||||
@@ -19,6 +19,12 @@ client_scripts {
|
||||
'source/client/main.lua',
|
||||
}
|
||||
|
||||
server_scripts {
|
||||
'config/config.lua',
|
||||
'source/server/db_migrate.lua',
|
||||
'source/server/mail.lua',
|
||||
}
|
||||
|
||||
files {
|
||||
'source/html/index.html',
|
||||
'source/html/assets/**',
|
||||
@@ -27,4 +33,3 @@ files {
|
||||
ui_page 'source/html/index.html'
|
||||
|
||||
dependency 'sky_base'
|
||||
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
local is_open = false
|
||||
local notification_focus = false
|
||||
|
||||
local mail_callbacks = {
|
||||
"mail:register",
|
||||
"mail:login",
|
||||
"mail:logout",
|
||||
"mail:counts",
|
||||
"mail:list",
|
||||
"mail:get",
|
||||
"mail:get-draft",
|
||||
"mail:save-draft",
|
||||
"mail:delete-draft",
|
||||
"mail:send",
|
||||
"mail:set-read",
|
||||
"mail:trash",
|
||||
"mail:restore",
|
||||
"mail:delete-forever",
|
||||
"mail:empty-trash",
|
||||
}
|
||||
|
||||
local function get_locale()
|
||||
return Locales[Sky.Config.locale] or Locales["en"]
|
||||
end
|
||||
@@ -64,6 +82,26 @@ RegisterNUICallback("notification:focus", function(data, cb)
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
for _, callback_name in ipairs(mail_callbacks) do
|
||||
RegisterNUICallback(callback_name, function(data, cb)
|
||||
local result = Sky.Cb.Trigger("sky_phone:" .. callback_name, data)
|
||||
if result then
|
||||
cb(result)
|
||||
return
|
||||
end
|
||||
|
||||
cb({ success = false, error = "request_failed" })
|
||||
end)
|
||||
end
|
||||
|
||||
RegisterNetEvent("sky_phone:mail:changed", function(data)
|
||||
SendNUIMessage({ type = "mail:changed", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:mail:new", function(data)
|
||||
SendNUIMessage({ type = "mail:new", data = data })
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
TriggerEvent("chat:addSuggestion", "/" .. Config.Command, get_locale().CommandDescription)
|
||||
end)
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sky Phone</title>
|
||||
<script type="module" crossorigin src="./assets/sky-index-DPyyh5lq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-xm7-yxvA.css">
|
||||
<script type="module" crossorigin src="./assets/sky-index-D43RW-Gb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-DXjB0u-M.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
local schema = {
|
||||
{
|
||||
name = "sky_phone_mail_accounts",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{
|
||||
name = "email",
|
||||
type = "VARCHAR(64) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_general_ci",
|
||||
},
|
||||
{ name = "password", type = "VARCHAR(64) NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_mail_email", columns = "(`email`)" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_mail_messages",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL" },
|
||||
{ name = "sender_account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "recipients", type = "LONGTEXT NOT NULL" },
|
||||
{ name = "subject", type = "VARCHAR(120) NOT NULL DEFAULT ''" },
|
||||
{ name = "body", type = "LONGTEXT NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_mail_sender", columns = "(`sender_account_id`, `created_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{
|
||||
column = "sender_account_id",
|
||||
references = "`sky_phone_mail_accounts` (`id`) ON DELETE CASCADE",
|
||||
},
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_mail_entries",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "message_id", type = "CHAR(36) NOT NULL" },
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "folder", type = "ENUM('inbox', 'sent') NOT NULL" },
|
||||
{ name = "read_at", type = "DATETIME NULL" },
|
||||
{ name = "trashed_at", type = "DATETIME NULL" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{
|
||||
name = "uniq_sky_phone_mail_entry",
|
||||
columns = "(`message_id`, `account_id`, `folder`)",
|
||||
},
|
||||
},
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_mailbox", columns = "(`account_id`, `folder`, `trashed_at`, `id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{
|
||||
column = "message_id",
|
||||
references = "`sky_phone_mail_messages` (`id`) ON DELETE CASCADE",
|
||||
},
|
||||
{
|
||||
column = "account_id",
|
||||
references = "`sky_phone_mail_accounts` (`id`) ON DELETE CASCADE",
|
||||
},
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_mail_drafts",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL" },
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "recipients", type = "LONGTEXT NOT NULL" },
|
||||
{ name = "subject", type = "VARCHAR(120) NOT NULL DEFAULT ''" },
|
||||
{ name = "body", type = "LONGTEXT NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{
|
||||
name = "updated_at",
|
||||
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
|
||||
},
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_mail_drafts", columns = "(`account_id`, `updated_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{
|
||||
column = "account_id",
|
||||
references = "`sky_phone_mail_accounts` (`id`) ON DELETE CASCADE",
|
||||
},
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
}
|
||||
|
||||
Sky.DB.Migrate("sky_phone", schema)
|
||||
@@ -0,0 +1,744 @@
|
||||
if not Sky.DB.AwaitMigrations("sky_phone") then
|
||||
error("[sky_phone] Mail database migrations did not complete.")
|
||||
end
|
||||
|
||||
local sessions = {}
|
||||
local account_sessions = {}
|
||||
local auth_attempts = {}
|
||||
|
||||
local function trim(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
|
||||
return value:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
local function validate_payload(source, operation, value)
|
||||
if type(value) == "table" then
|
||||
return value
|
||||
end
|
||||
|
||||
Sky.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Invalid mail payload for %s from source %s.",
|
||||
operation,
|
||||
tostring(source)
|
||||
)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function text_length(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
|
||||
return utf8.len(value)
|
||||
end
|
||||
|
||||
local function normalize_email(value)
|
||||
local email = trim(value)
|
||||
if not email then
|
||||
return nil
|
||||
end
|
||||
|
||||
email = email:lower()
|
||||
local local_part = email
|
||||
if email:find("@", 1, true) then
|
||||
local_part = email:match("^([^@]+)@" .. Config.Mail.Domain:gsub("%.", "%%.") .. "$")
|
||||
end
|
||||
|
||||
if not local_part
|
||||
or #local_part < Config.Mail.LocalPartMinLength
|
||||
or #local_part > Config.Mail.LocalPartMaxLength
|
||||
or not local_part:match("^[a-z0-9][a-z0-9._-]*[a-z0-9]$")
|
||||
or local_part:find("..", 1, true)
|
||||
then
|
||||
return nil
|
||||
end
|
||||
|
||||
return local_part .. "@" .. Config.Mail.Domain
|
||||
end
|
||||
|
||||
local function validate_password(value)
|
||||
local length = text_length(value)
|
||||
return length and length >= Config.Mail.PasswordMinLength and length <= Config.Mail.PasswordMaxLength
|
||||
end
|
||||
|
||||
local function validate_text(value, maximum)
|
||||
local length = text_length(value)
|
||||
return length and length <= maximum
|
||||
end
|
||||
|
||||
local function normalize_recipients(values, strict)
|
||||
if type(values) ~= "table" or #values > Config.Mail.MaxRecipients then
|
||||
return nil
|
||||
end
|
||||
|
||||
local recipients = {}
|
||||
local seen = {}
|
||||
for _, value in ipairs(values) do
|
||||
local recipient
|
||||
if strict then
|
||||
recipient = normalize_email(value)
|
||||
else
|
||||
recipient = trim(value)
|
||||
if recipient and #recipient > 64 then
|
||||
recipient = nil
|
||||
end
|
||||
end
|
||||
|
||||
if not recipient then
|
||||
return nil
|
||||
end
|
||||
|
||||
local key = recipient:lower()
|
||||
if key ~= "" and not seen[key] then
|
||||
seen[key] = true
|
||||
recipients[#recipients + 1] = recipient
|
||||
end
|
||||
end
|
||||
|
||||
return recipients
|
||||
end
|
||||
|
||||
local function allow_auth_attempt(source)
|
||||
local now = os.time()
|
||||
local attempts = auth_attempts[source]
|
||||
if not attempts or now - attempts.started_at >= 60 then
|
||||
auth_attempts[source] = { count = 1, started_at = now }
|
||||
return true
|
||||
end
|
||||
|
||||
if attempts.count >= Config.Mail.AuthAttemptsPerMinute then
|
||||
return false
|
||||
end
|
||||
|
||||
attempts.count = attempts.count + 1
|
||||
return true
|
||||
end
|
||||
|
||||
local function clear_session(source)
|
||||
local session = sessions[source]
|
||||
if not session then
|
||||
return
|
||||
end
|
||||
|
||||
local key = tostring(session.id)
|
||||
sessions[source] = nil
|
||||
if account_sessions[key] then
|
||||
account_sessions[key][source] = nil
|
||||
if not next(account_sessions[key]) then
|
||||
account_sessions[key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function set_session(source, account)
|
||||
clear_session(source)
|
||||
sessions[source] = { id = account.id, email = account.email }
|
||||
local key = tostring(account.id)
|
||||
account_sessions[key] = account_sessions[key] or {}
|
||||
account_sessions[key][source] = true
|
||||
end
|
||||
|
||||
local function require_session(source)
|
||||
local session = sessions[source]
|
||||
if not session then
|
||||
return nil, { success = false, error = "not_authenticated" }
|
||||
end
|
||||
|
||||
return session
|
||||
end
|
||||
|
||||
local function new_database_id()
|
||||
local rows = Sky.Query("SELECT UUID() AS id", {})
|
||||
if not rows[1] or type(rows[1].id) ~= "string" then
|
||||
error("[sky_phone] Database did not generate a mail id.")
|
||||
end
|
||||
|
||||
return rows[1].id
|
||||
end
|
||||
|
||||
local function notify_account(account_id, event_name, data)
|
||||
local sources = account_sessions[tostring(account_id)]
|
||||
if not sources then
|
||||
return
|
||||
end
|
||||
|
||||
for source in pairs(sources) do
|
||||
TriggerClientEvent(event_name, source, data)
|
||||
end
|
||||
end
|
||||
|
||||
local function get_counts(account_id)
|
||||
local rows = Sky.Query([[
|
||||
SELECT
|
||||
SUM(CASE WHEN `folder` = 'inbox' AND `trashed_at` IS NULL AND `read_at` IS NULL THEN 1 ELSE 0 END) AS unread,
|
||||
SUM(CASE WHEN `folder` = 'inbox' AND `trashed_at` IS NULL THEN 1 ELSE 0 END) AS inbox,
|
||||
SUM(CASE WHEN `folder` = 'sent' AND `trashed_at` IS NULL THEN 1 ELSE 0 END) AS sent,
|
||||
SUM(CASE WHEN `trashed_at` IS NOT NULL THEN 1 ELSE 0 END) AS trash
|
||||
FROM `sky_phone_mail_entries`
|
||||
WHERE `account_id` = ?
|
||||
]], { account_id })
|
||||
local drafts = Sky.Query(
|
||||
"SELECT COUNT(*) AS count FROM `sky_phone_mail_drafts` WHERE `account_id` = ?",
|
||||
{ account_id }
|
||||
)
|
||||
local counts = rows[1] or {}
|
||||
|
||||
return {
|
||||
unread = tonumber(counts.unread) or 0,
|
||||
inbox = tonumber(counts.inbox) or 0,
|
||||
sent = tonumber(counts.sent) or 0,
|
||||
trash = tonumber(counts.trash) or 0,
|
||||
drafts = tonumber(drafts[1] and drafts[1].count) or 0,
|
||||
}
|
||||
end
|
||||
|
||||
local function broadcast_mailbox_changed(account_id)
|
||||
notify_account(account_id, "sky_phone:mail:changed", {
|
||||
counts = get_counts(account_id),
|
||||
})
|
||||
end
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:register", function(source, data)
|
||||
if not allow_auth_attempt(source) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
|
||||
data = validate_payload(source, "register", data)
|
||||
if not data then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local email = normalize_email(data.email)
|
||||
local password = data.password
|
||||
if not email then
|
||||
return { success = false, error = "invalid_email" }
|
||||
end
|
||||
if not validate_password(password) then
|
||||
return { success = false, error = "invalid_password" }
|
||||
end
|
||||
|
||||
local existing = Sky.Query(
|
||||
"SELECT `id` FROM `sky_phone_mail_accounts` WHERE `email` = ? LIMIT 1",
|
||||
{ email }
|
||||
)
|
||||
if existing[1] then
|
||||
return { success = false, error = "email_taken" }
|
||||
end
|
||||
|
||||
local result = Sky.Query(
|
||||
"INSERT INTO `sky_phone_mail_accounts` (`email`, `password`) VALUES (?, ?)",
|
||||
{ email, password }
|
||||
)
|
||||
if not result or result == 0 or (type(result) == "table" and result.affectedRows == 0) then
|
||||
return { success = false, error = "email_taken" }
|
||||
end
|
||||
|
||||
local accounts = Sky.Query(
|
||||
"SELECT `id`, `email` FROM `sky_phone_mail_accounts` WHERE `email` = ? LIMIT 1",
|
||||
{ email }
|
||||
)
|
||||
if not accounts[1] then
|
||||
error("[sky_phone] Registered mail account could not be loaded.")
|
||||
end
|
||||
|
||||
set_session(source, accounts[1])
|
||||
return {
|
||||
success = true,
|
||||
data = { email = email, counts = get_counts(accounts[1].id) },
|
||||
}
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:login", function(source, data)
|
||||
if not allow_auth_attempt(source) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
|
||||
data = validate_payload(source, "login", data)
|
||||
if not data then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local email = normalize_email(data.email)
|
||||
local password = data.password
|
||||
if not email or not validate_password(password) then
|
||||
return { success = false, error = "invalid_credentials" }
|
||||
end
|
||||
|
||||
local accounts = Sky.Query(
|
||||
"SELECT `id`, `email` FROM `sky_phone_mail_accounts` WHERE `email` = ? AND `password` = ? LIMIT 1",
|
||||
{ email, password }
|
||||
)
|
||||
if not accounts[1] then
|
||||
return { success = false, error = "invalid_credentials" }
|
||||
end
|
||||
|
||||
set_session(source, accounts[1])
|
||||
return {
|
||||
success = true,
|
||||
data = { email = accounts[1].email, counts = get_counts(accounts[1].id) },
|
||||
}
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:logout", function(source)
|
||||
clear_session(source)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:counts", function(source)
|
||||
local session, error_response = require_session(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
return { success = true, data = get_counts(session.id) }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:list", function(source, data)
|
||||
local session, error_response = require_session(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
data = validate_payload(source, "list", data)
|
||||
if not data then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local folder = data.folder
|
||||
if folder ~= "inbox" and folder ~= "sent" and folder ~= "drafts" and folder ~= "trash" then
|
||||
return { success = false, error = "invalid_folder" }
|
||||
end
|
||||
|
||||
local search = trim(data.search) or ""
|
||||
if not validate_text(search, 120) then
|
||||
return { success = false, error = "invalid_search" }
|
||||
end
|
||||
local offset = math.max(0, math.min(100000, math.floor(tonumber(data.offset) or 0)))
|
||||
local limit = Config.Mail.PageSize + 1
|
||||
local rows
|
||||
|
||||
if folder == "drafts" then
|
||||
local pattern = "%" .. search .. "%"
|
||||
rows = Sky.Query([[
|
||||
SELECT `id`, `recipients`, `subject`, LEFT(`body`, 180) AS `preview`, `updated_at` AS `created_at`
|
||||
FROM `sky_phone_mail_drafts`
|
||||
WHERE `account_id` = ? AND (? = '' OR `subject` LIKE ? OR `body` LIKE ? OR `recipients` LIKE ?)
|
||||
ORDER BY `updated_at` DESC
|
||||
LIMIT ? OFFSET ?
|
||||
]], { session.id, search, pattern, pattern, pattern, limit, offset })
|
||||
else
|
||||
local conditions
|
||||
local values = { session.id }
|
||||
if folder == "trash" then
|
||||
conditions = "e.`trashed_at` IS NOT NULL"
|
||||
else
|
||||
conditions = "e.`folder` = ? AND e.`trashed_at` IS NULL"
|
||||
values[#values + 1] = folder
|
||||
end
|
||||
|
||||
local pattern = "%" .. search .. "%"
|
||||
values[#values + 1] = search
|
||||
values[#values + 1] = pattern
|
||||
values[#values + 1] = pattern
|
||||
values[#values + 1] = pattern
|
||||
values[#values + 1] = pattern
|
||||
values[#values + 1] = limit
|
||||
values[#values + 1] = offset
|
||||
|
||||
rows = Sky.Query(([[
|
||||
SELECT e.`id`, e.`folder`, e.`read_at`, e.`trashed_at`, m.`id` AS `message_id`,
|
||||
sender.`email` AS `sender`, m.`recipients`, m.`subject`, LEFT(m.`body`, 180) AS `preview`,
|
||||
m.`created_at`
|
||||
FROM `sky_phone_mail_entries` e
|
||||
JOIN `sky_phone_mail_messages` m ON m.`id` = e.`message_id`
|
||||
JOIN `sky_phone_mail_accounts` sender ON sender.`id` = m.`sender_account_id`
|
||||
WHERE e.`account_id` = ? AND %s
|
||||
AND (? = '' OR m.`subject` LIKE ? OR m.`body` LIKE ? OR sender.`email` LIKE ? OR m.`recipients` LIKE ?)
|
||||
ORDER BY m.`created_at` DESC, e.`id` DESC
|
||||
LIMIT ? OFFSET ?
|
||||
]]):format(conditions), values)
|
||||
end
|
||||
|
||||
local has_more = #rows > Config.Mail.PageSize
|
||||
if has_more then
|
||||
rows[#rows] = nil
|
||||
end
|
||||
for _, row in ipairs(rows) do
|
||||
row.recipients = json.decode(row.recipients) or {}
|
||||
row.is_read = row.read_at ~= nil
|
||||
row.read_at = nil
|
||||
end
|
||||
|
||||
return {
|
||||
success = true,
|
||||
data = { items = rows, hasMore = has_more, offset = offset },
|
||||
}
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:get", function(source, data)
|
||||
local session, error_response = require_session(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
data = validate_payload(source, "get", data)
|
||||
if not data then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local entry_id = tonumber(data.id)
|
||||
if not entry_id then
|
||||
return { success = false, error = "invalid_message" }
|
||||
end
|
||||
|
||||
local rows = Sky.Query([[
|
||||
SELECT e.`id`, e.`folder`, e.`read_at`, e.`trashed_at`, m.`id` AS `message_id`,
|
||||
sender.`email` AS `sender`, m.`recipients`, m.`subject`, m.`body`, m.`created_at`
|
||||
FROM `sky_phone_mail_entries` e
|
||||
JOIN `sky_phone_mail_messages` m ON m.`id` = e.`message_id`
|
||||
JOIN `sky_phone_mail_accounts` sender ON sender.`id` = m.`sender_account_id`
|
||||
WHERE e.`id` = ? AND e.`account_id` = ?
|
||||
LIMIT 1
|
||||
]], { entry_id, session.id })
|
||||
if not rows[1] then
|
||||
return { success = false, error = "message_not_found" }
|
||||
end
|
||||
|
||||
if not rows[1].read_at then
|
||||
Sky.Query(
|
||||
"UPDATE `sky_phone_mail_entries` SET `read_at` = CURRENT_TIMESTAMP WHERE `id` = ? AND `account_id` = ?",
|
||||
{ entry_id, session.id }
|
||||
)
|
||||
rows[1].read_at = os.date("%Y-%m-%d %H:%M:%S")
|
||||
broadcast_mailbox_changed(session.id)
|
||||
end
|
||||
|
||||
rows[1].recipients = json.decode(rows[1].recipients) or {}
|
||||
rows[1].is_read = true
|
||||
rows[1].read_at = nil
|
||||
return { success = true, data = rows[1] }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:get-draft", function(source, data)
|
||||
local session, error_response = require_session(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
data = validate_payload(source, "get-draft", data)
|
||||
if not data then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local id = data.id
|
||||
if type(id) ~= "string" or #id ~= 36 then
|
||||
return { success = false, error = "invalid_draft" }
|
||||
end
|
||||
|
||||
local rows = Sky.Query([[
|
||||
SELECT `id`, `recipients`, `subject`, `body`, `created_at`, `updated_at`
|
||||
FROM `sky_phone_mail_drafts`
|
||||
WHERE `id` = ? AND `account_id` = ?
|
||||
LIMIT 1
|
||||
]], { id, session.id })
|
||||
if not rows[1] then
|
||||
return { success = false, error = "draft_not_found" }
|
||||
end
|
||||
|
||||
rows[1].recipients = json.decode(rows[1].recipients) or {}
|
||||
return { success = true, data = rows[1] }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:save-draft", function(source, data)
|
||||
local session, error_response = require_session(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
data = validate_payload(source, "save-draft", data)
|
||||
if not data then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local recipients = normalize_recipients(data.recipients, false)
|
||||
local subject = data.subject
|
||||
local body = data.body
|
||||
if not recipients or not validate_text(subject, Config.Mail.SubjectMaxLength)
|
||||
or not validate_text(body, Config.Mail.BodyMaxLength)
|
||||
then
|
||||
return { success = false, error = "invalid_draft" }
|
||||
end
|
||||
|
||||
local id = data.id
|
||||
if id ~= nil and (type(id) ~= "string" or #id ~= 36) then
|
||||
return { success = false, error = "invalid_draft" }
|
||||
end
|
||||
|
||||
if id then
|
||||
local result = Sky.Query([[
|
||||
UPDATE `sky_phone_mail_drafts`
|
||||
SET `recipients` = ?, `subject` = ?, `body` = ?, `updated_at` = CURRENT_TIMESTAMP
|
||||
WHERE `id` = ? AND `account_id` = ?
|
||||
]], { json.encode(recipients), subject, body, id, session.id })
|
||||
if not result or result == 0 or (type(result) == "table" and result.affectedRows == 0) then
|
||||
return { success = false, error = "draft_not_found" }
|
||||
end
|
||||
else
|
||||
id = new_database_id()
|
||||
Sky.Query([[
|
||||
INSERT INTO `sky_phone_mail_drafts` (`id`, `account_id`, `recipients`, `subject`, `body`)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
]], { id, session.id, json.encode(recipients), subject, body })
|
||||
end
|
||||
|
||||
broadcast_mailbox_changed(session.id)
|
||||
return { success = true, data = { id = id } }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:delete-draft", function(source, data)
|
||||
local session, error_response = require_session(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
data = validate_payload(source, "delete-draft", data)
|
||||
if not data then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local id = data.id
|
||||
if type(id) ~= "string" or #id ~= 36 then
|
||||
return { success = false, error = "invalid_draft" }
|
||||
end
|
||||
|
||||
Sky.Query(
|
||||
"DELETE FROM `sky_phone_mail_drafts` WHERE `id` = ? AND `account_id` = ?",
|
||||
{ id, session.id }
|
||||
)
|
||||
broadcast_mailbox_changed(session.id)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:send", function(source, data)
|
||||
local session, error_response = require_session(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
data = validate_payload(source, "send", data)
|
||||
if not data then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local recipients = normalize_recipients(data.recipients, true)
|
||||
local subject = trim(data.subject)
|
||||
local body = data.body
|
||||
if not recipients or #recipients == 0
|
||||
or not validate_text(subject, Config.Mail.SubjectMaxLength)
|
||||
or not validate_text(body, Config.Mail.BodyMaxLength)
|
||||
or (subject == "" and trim(body) == "")
|
||||
then
|
||||
return { success = false, error = "invalid_message" }
|
||||
end
|
||||
|
||||
local placeholders = {}
|
||||
for index = 1, #recipients do
|
||||
placeholders[index] = "?"
|
||||
end
|
||||
local recipient_accounts = Sky.Query(
|
||||
("SELECT `id`, `email` FROM `sky_phone_mail_accounts` WHERE `email` IN (%s)"):format(table.concat(placeholders, ", ")),
|
||||
recipients
|
||||
)
|
||||
if #recipient_accounts ~= #recipients then
|
||||
return { success = false, error = "recipient_not_found" }
|
||||
end
|
||||
|
||||
local message_id = new_database_id()
|
||||
local statements = {
|
||||
{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_mail_messages`
|
||||
(`id`, `sender_account_id`, `recipients`, `subject`, `body`)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
]],
|
||||
params = { message_id, session.id, json.encode(recipients), subject, body },
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_mail_entries` (`message_id`, `account_id`, `folder`, `read_at`)
|
||||
VALUES (?, ?, 'sent', CURRENT_TIMESTAMP)
|
||||
]],
|
||||
params = { message_id, session.id },
|
||||
},
|
||||
}
|
||||
for _, account in ipairs(recipient_accounts) do
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_mail_entries` (`message_id`, `account_id`, `folder`)
|
||||
VALUES (?, ?, 'inbox')
|
||||
]],
|
||||
params = { message_id, account.id },
|
||||
}
|
||||
end
|
||||
if type(data.draftId) == "string" and #data.draftId == 36 then
|
||||
statements[#statements + 1] = {
|
||||
query = "DELETE FROM `sky_phone_mail_drafts` WHERE `id` = ? AND `account_id` = ?",
|
||||
params = { data.draftId, session.id },
|
||||
}
|
||||
end
|
||||
|
||||
if not Sky.DB.Transaction(statements) then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
|
||||
broadcast_mailbox_changed(session.id)
|
||||
for _, account in ipairs(recipient_accounts) do
|
||||
notify_account(account.id, "sky_phone:mail:new", {
|
||||
sender = session.email,
|
||||
subject = subject,
|
||||
})
|
||||
broadcast_mailbox_changed(account.id)
|
||||
end
|
||||
|
||||
return { success = true, data = { id = message_id } }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:set-read", function(source, data)
|
||||
local session, error_response = require_session(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
data = validate_payload(source, "set-read", data)
|
||||
if not data then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local id = tonumber(data.id)
|
||||
if not id or type(data.read) ~= "boolean" then
|
||||
return { success = false, error = "invalid_message" }
|
||||
end
|
||||
|
||||
Sky.Query(
|
||||
("UPDATE `sky_phone_mail_entries` SET `read_at` = %s WHERE `id` = ? AND `account_id` = ?")
|
||||
:format(data.read and "CURRENT_TIMESTAMP" or "NULL"),
|
||||
{ id, session.id }
|
||||
)
|
||||
broadcast_mailbox_changed(session.id)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:trash", function(source, data)
|
||||
local session, error_response = require_session(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
data = validate_payload(source, "trash", data)
|
||||
if not data then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local id = tonumber(data.id)
|
||||
if not id then
|
||||
return { success = false, error = "invalid_message" }
|
||||
end
|
||||
|
||||
Sky.Query([[
|
||||
UPDATE `sky_phone_mail_entries` SET `trashed_at` = CURRENT_TIMESTAMP
|
||||
WHERE `id` = ? AND `account_id` = ? AND `trashed_at` IS NULL
|
||||
]], { id, session.id })
|
||||
broadcast_mailbox_changed(session.id)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:restore", function(source, data)
|
||||
local session, error_response = require_session(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
data = validate_payload(source, "restore", data)
|
||||
if not data then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local id = tonumber(data.id)
|
||||
if not id then
|
||||
return { success = false, error = "invalid_message" }
|
||||
end
|
||||
|
||||
Sky.Query([[
|
||||
UPDATE `sky_phone_mail_entries` SET `trashed_at` = NULL
|
||||
WHERE `id` = ? AND `account_id` = ? AND `trashed_at` IS NOT NULL
|
||||
]], { id, session.id })
|
||||
broadcast_mailbox_changed(session.id)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:delete-forever", function(source, data)
|
||||
local session, error_response = require_session(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
data = validate_payload(source, "delete-forever", data)
|
||||
if not data then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local id = tonumber(data.id)
|
||||
if not id then
|
||||
return { success = false, error = "invalid_message" }
|
||||
end
|
||||
|
||||
Sky.Query([[
|
||||
DELETE FROM `sky_phone_mail_entries`
|
||||
WHERE `id` = ? AND `account_id` = ? AND `trashed_at` IS NOT NULL
|
||||
]], { id, session.id })
|
||||
Sky.Query([[
|
||||
DELETE m FROM `sky_phone_mail_messages` m
|
||||
LEFT JOIN `sky_phone_mail_entries` e ON e.`message_id` = m.`id`
|
||||
WHERE e.`id` IS NULL
|
||||
]], {})
|
||||
broadcast_mailbox_changed(session.id)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Sky.Cb.Register("sky_phone:mail:empty-trash", function(source)
|
||||
local session, error_response = require_session(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
Sky.Query(
|
||||
"DELETE FROM `sky_phone_mail_entries` WHERE `account_id` = ? AND `trashed_at` IS NOT NULL",
|
||||
{ session.id }
|
||||
)
|
||||
Sky.Query([[
|
||||
DELETE m FROM `sky_phone_mail_messages` m
|
||||
LEFT JOIN `sky_phone_mail_entries` e ON e.`message_id` = m.`id`
|
||||
WHERE e.`id` IS NULL
|
||||
]], {})
|
||||
broadcast_mailbox_changed(session.id)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
AddEventHandler("playerDropped", function()
|
||||
auth_attempts[source] = nil
|
||||
clear_session(source)
|
||||
end)
|
||||
|
||||
AddEventHandler("onResourceStop", function(resource_name)
|
||||
if resource_name ~= GetCurrentResourceName() then
|
||||
return
|
||||
end
|
||||
|
||||
sessions = {}
|
||||
account_sessions = {}
|
||||
auth_attempts = {}
|
||||
end)
|
||||
Reference in New Issue
Block a user