From 2066d31f711d39e2d819f40e15ec5cbd4c245693 Mon Sep 17 00:00:00 2001 From: Eichenholz Date: Tue, 4 Aug 2026 12:25:15 +0200 Subject: [PATCH] ADD - mail app --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 532 +++++++++++++ frontend/src/assets/img/app-icons/mail.webp | Bin 0 -> 7214 bytes frontend/src/assets/img/app-icons/notes.webp | Bin 4162 -> 5186 bytes frontend/src/assets/main.css | 7 +- frontend/src/config/apps.test.ts | 5 + frontend/src/config/apps.ts | 23 +- frontend/src/stores/mail.test.ts | 112 +++ frontend/src/stores/mail.ts | 185 +++++ frontend/src/stores/phone.ts | 68 +- frontend/src/types/apps.ts | 1 + frontend/src/types/mail.ts | 59 ++ frontend/src/utils/mail.test.ts | 59 ++ frontend/src/utils/mail.ts | 77 ++ frontend/src/utils/nui.ts | 5 +- frontend/src/utils/preferences.test.ts | 4 + frontend/src/utils/preferences.ts | 1 + frontend/src/views/apps/MailApp.vue | 733 ++++++++++++++++++ frontend/src/views/apps/NotesApp.vue | 137 ++-- frontend/testserver/index.cjs | 186 ++++- sky_phone/config/config.lua | 12 + sky_phone/config/locales/en.lua | 33 +- sky_phone/fxmanifest.lua | 7 +- sky_phone/source/client/main.lua | 38 + sky_phone/source/html/index.html | 4 +- sky_phone/source/server/db_migrate.lua | 103 +++ sky_phone/source/server/mail.lua | 744 +++++++++++++++++++ 27 files changed, 3063 insertions(+), 73 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/mail.webp create mode 100644 frontend/src/stores/mail.test.ts create mode 100644 frontend/src/stores/mail.ts create mode 100644 frontend/src/types/mail.ts create mode 100644 frontend/src/utils/mail.test.ts create mode 100644 frontend/src/utils/mail.ts create mode 100644 frontend/src/views/apps/MailApp.vue create mode 100644 sky_phone/source/server/db_migrate.lua create mode 100644 sky_phone/source/server/mail.lua diff --git a/frontend/package.json b/frontend/package.json index 0ab63a6..944bab1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 18aa805..3620d6d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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: {} diff --git a/frontend/src/assets/img/app-icons/mail.webp b/frontend/src/assets/img/app-icons/mail.webp new file mode 100644 index 0000000000000000000000000000000000000000..fdfb9eab245eacb896fd3b0baf56628608525a87 GIT binary patch literal 7214 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�jc{eQiGuxFzdZ>O5Tl(GwMLuFNDi>^=*S*U2YFx*)M_ zVr}vGT``R}CIOAW8apz}7{$D(s6wTE=LZ~?tr*RqQH>x4TgYDvo`nN zyLjY2t_eRiI#h%M1I2Hi&+#S_#4xnKQ#!bOQ}U#Y{>{>dyv$_j)VQmr>-%RJw-$aG zI#I*yo*Ed86H}IA*NCC^J>xP5VX6NqOG<*DFbMY=y5V(oe_% zE10$1J#HNmJ2gx;P(M6C$gzK#Wds_)EtVrvRYUWJ9ZUE0GEmQcMb|G2Cc+1{gygj3 zTdZd488jPfo)gm9o8*h%kKTTy!VklN&gjf~8=$_ZGGW$&%sTK8ahk$Fi~so;jD{Ieq7*E=CmRnEt{&P*mr zx+bmMlI8;eA-j=k53Wz7cur-E$}uNy;Lw%F`_(T4xpxp!mMe^T>9PI9(@)tdLHI_s`k*kgkNiNWTBU~bu>suebaD4fM^W}Liu6$_)%jm{Axtv}^^8Hl z+3((fp|SUCo7Zw*j-F#EQ^;|I=1Hc`Zc|)^eWAeb)XI~|zjlso07OXOGZrE%R(y55 zP5{@nfx z-5d?z+l#k3>Cw1ZM+ZN*I^k@q?=5_p-6w>PG>IP3Q)bN^ler$g&ZTk2W}0Ey&vZh? zc|ct?_c)tj95X>g=Om?VzbGrLzT(ucdy)iF3@m-myTk?HCSKD*OpLbmWQk@*odh$B z=Q&!N*_M*3$ILFmOxD_J_0z2}hV5Ghb20)HjSWxb&>r~&!w>`;t7U5D8y-x)5%9YEd^W9FB}KLi`ks-JrXijcoH&>(?sJAEHFqPIJmvJH1h8Xt@-cI qCJhe^Pa3~YlS*0HFWq@8z51HFUGDv{7qtnwcxcwG?vnoREBJqQ7Vr`P literal 0 HcmV?d00001 diff --git a/frontend/src/assets/img/app-icons/notes.webp b/frontend/src/assets/img/app-icons/notes.webp index 959a61aef1257a31ecd0217b74ddf00031d6b3da..5058ef44f6a8a30cb5e64d207d37a3e6bdfc1528 100644 GIT binary patch literal 5186 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 literal 4162 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 M?!v4!|D3=80O6@Jh5!Hn diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 8ae99eb..6ddb7e6 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -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; diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 0138497..483e629 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -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)) diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index a9469bb..b539ffc 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -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, diff --git a/frontend/src/stores/mail.test.ts b/frontend/src/stores/mail.test.ts new file mode 100644 index 0000000..f2c2295 --- /dev/null +++ b/frontend/src/stores/mail.test.ts @@ -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) + }) +}) diff --git a/frontend/src/stores/mail.ts b/frontend/src/stores/mail.ts new file mode 100644 index 0000000..14ea9cc --- /dev/null +++ b/frontend/src/stores/mail.ts @@ -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(emptyCounts()) + const folder = ref('inbox') + const hasMore = ref(false) + const items = ref([]) + 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('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('mail:register', { + email, + password, + }) + if (response.success && response.data) applySession(response.data) + return response + } + + async function logout(): Promise { + 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 { + loading.value = true + const offset = append ? items.value.length : 0 + const response = await nuiCall('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 { + const response = await nuiCall('mail:counts') + if (response.success && response.data) counts.value = response.data + } + + async function openMessage(id: number): Promise { + const response = await nuiCall('mail:get', { id }) + if (!response.success || !response.data) return null + await refreshCounts() + return response.data + } + + async function openDraft(id: string): Promise { + const response = await nuiCall('mail:get-draft', { id }) + return response.success && response.data ? response.data : null + } + + async function saveDraft(draft: MailComposeDraft): Promise { + 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 { + 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 = {}, + ): Promise { + 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 { + 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 + }, + } +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index fe34e67..fd6c5df 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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', }, diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 377fd57..a199781 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -4,6 +4,7 @@ export type PhoneAppId = | 'calculator' | 'camera' | 'clock' + | 'mail' | 'notes' | 'photos' | 'app-store' diff --git a/frontend/src/types/mail.ts b/frontend/src/types/mail.ts new file mode 100644 index 0000000..2815d2a --- /dev/null +++ b/frontend/src/types/mail.ts @@ -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 +} diff --git a/frontend/src/utils/mail.test.ts b/frontend/src/utils/mail.test.ts new file mode 100644 index 0000000..33745bf --- /dev/null +++ b/frontend/src/utils/mail.test.ts @@ -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') + }) +}) diff --git a/frontend/src/utils/mail.ts b/frontend/src/utils/mail.ts new file mode 100644 index 0000000..fb044de --- /dev/null +++ b/frontend/src/utils/mail.ts @@ -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() + 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), + } +} diff --git a/frontend/src/utils/nui.ts b/frontend/src/utils/nui.ts index 5b03a5d..d189ddd 100644 --- a/frontend/src/utils/nui.ts +++ b/frontend/src/utils/nui.ts @@ -10,8 +10,11 @@ export async function nuiCall( endpoint: string, data: Record = {}, ): Promise> { + 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 { diff --git a/frontend/src/utils/preferences.test.ts b/frontend/src/utils/preferences.test.ts index e67e753..bf46e55 100644 --- a/frontend/src/utils/preferences.test.ts +++ b/frontend/src/utils/preferences.test.ts @@ -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') }) diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index e929633..ac29aa6 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -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 }, diff --git a/frontend/src/views/apps/MailApp.vue b/frontend/src/views/apps/MailApp.vue new file mode 100644 index 0000000..cbaa8f7 --- /dev/null +++ b/frontend/src/views/apps/MailApp.vue @@ -0,0 +1,733 @@ + + + diff --git a/frontend/src/views/apps/NotesApp.vue b/frontend/src/views/apps/NotesApp.vue index 8e382e2..be37898 100644 --- a/frontend/src/views/apps/NotesApp.vue +++ b/frontend/src/views/apps/NotesApp.vue @@ -1,12 +1,7 @@