diff --git a/package.json b/package.json index 67c34e3..a296645 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@types/dompurify": "^3.0.5", "dompurify": "^3.3.3", + "jsdom": "^29.0.2", "marked": "^17.0.5", "next": "16.2.0", "next-auth": "^5.0.0-beta.30", @@ -23,6 +24,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/jsdom": "^28.0.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85eb458..e508da9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: dompurify: specifier: ^3.3.3 version: 3.3.3 + jsdom: + specifier: ^29.0.2 + version: 29.0.2 marked: specifier: ^17.0.5 version: 17.0.5 @@ -36,6 +39,9 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.2.2 + '@types/jsdom': + specifier: ^28.0.1 + version: 28.0.1 '@types/node': specifier: ^20 version: 20.19.37 @@ -67,6 +73,21 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@auth/core@0.41.0': resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==} peerDependencies: @@ -148,6 +169,46 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.0': + resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} @@ -351,6 +412,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -703,6 +773,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/jsdom@28.0.1': + resolution: {integrity: sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -720,6 +793,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -968,6 +1044,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1027,12 +1106,20 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -1062,6 +1149,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1098,6 +1188,14 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-abstract@1.24.1: resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} engines: {node: '>= 0.4'} @@ -1409,6 +1507,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1496,6 +1598,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -1556,6 +1661,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.0.2: + resolution: {integrity: sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1678,6 +1792,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.3.5: + resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1693,6 +1811,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1830,6 +1951,12 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1926,6 +2053,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1966,6 +2097,10 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2088,6 +2223,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@4.2.2: resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} @@ -2103,10 +2241,25 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tldts-core@7.0.28: + resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} + + tldts@7.0.28: + resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2163,6 +2316,13 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.25.0: + resolution: {integrity: sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==} + + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -2175,6 +2335,22 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2200,6 +2376,13 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2220,6 +2403,26 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@auth/core@0.41.0': dependencies: '@panva/hkdf': 1.2.1 @@ -2328,6 +2531,34 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -2468,6 +2699,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.0': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -2736,6 +2969,13 @@ snapshots: '@types/estree@1.0.8': {} + '@types/jsdom@28.0.1': + dependencies: + '@types/node': 20.19.37 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + undici-types: 7.25.0 + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -2752,6 +2992,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': optional: true @@ -3013,6 +3255,10 @@ snapshots: baseline-browser-mapping@2.10.9: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -3078,10 +3324,22 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -3108,6 +3366,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -3147,6 +3407,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@6.0.1: {} + + entities@8.0.0: {} + es-abstract@1.24.1: dependencies: array-buffer-byte-length: 1.0.2 @@ -3630,6 +3894,12 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3720,6 +3990,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -3782,6 +4054,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.0.2: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.5 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -3877,6 +4175,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.3.5: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -3889,6 +4189,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -4026,6 +4328,14 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -4125,6 +4435,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -4171,6 +4483,10 @@ snapshots: safe-stable-stringify@2.5.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -4347,6 +4663,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwindcss@4.2.2: {} tapable@2.3.0: {} @@ -4360,10 +4678,24 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tldts-core@7.0.28: {} + + tldts@7.0.28: + dependencies: + tldts-core: 7.0.28 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.28 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -4443,6 +4775,10 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.25.0: {} + + undici@7.25.0: {} + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -4477,6 +4813,22 @@ snapshots: dependencies: punycode: 2.3.1 + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4524,6 +4876,10 @@ snapshots: word-wrap@1.2.5: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/src/app/(inbox)/actions.ts b/src/app/(inbox)/actions.ts index b6207cf..89549f9 100644 --- a/src/app/(inbox)/actions.ts +++ b/src/app/(inbox)/actions.ts @@ -1,17 +1,24 @@ "use server"; -import { getSession, getAccountId, listEmails, loadMoreEmailsFiltered, searchEmails, setPin, setKeywordsOnMany, moveEmailsToMailbox } from "@/lib/jmap"; +import { auth } from "@/auth"; +import { getSession, getAccountId, getMailboxes, listEmails, loadMoreEmailsFiltered, searchEmails, setPin, setKeywordsOnMany, moveEmailsToMailbox } from "@/lib/jmap"; import { parseSearchQuery, buildJmapFilter } from "@/lib/search"; import { log } from "@/lib/logger"; import { Email } from "@/lib/types"; +async function requireAuthedJmap() { + const sessionData = await auth(); + if (!sessionData?.user) throw new Error("Unauthorized"); + const session = await getSession(); + return { session, accountId: getAccountId(session) }; +} + export async function loadMoreEmails( inboxId: string, position: number ): Promise<{ emails: Email[]; total: number }> { const t = Date.now(); - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await requireAuthedJmap(); const result = await listEmails(session.apiUrl, accountId, inboxId, 50, position); log.info({ mailbox_id: inboxId, position, limit: 50, returned: result.emails.length, total: result.total, duration_ms: Date.now() - t }, "action.load_more"); return result; @@ -22,8 +29,7 @@ export async function loadMoreUnreads( position: number ): Promise<{ emails: Email[]; total: number }> { const t = Date.now(); - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await requireAuthedJmap(); const result = await loadMoreEmailsFiltered(session.apiUrl, accountId, inboxId, "unread", position); log.info({ mailbox_id: inboxId, filter: "unread", position, limit: 50, returned: result.emails.length, total: result.total, duration_ms: Date.now() - t }, "action.load_more"); return result; @@ -34,8 +40,7 @@ export async function loadMoreReads( position: number ): Promise<{ emails: Email[]; total: number }> { const t = Date.now(); - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await requireAuthedJmap(); const result = await loadMoreEmailsFiltered(session.apiUrl, accountId, inboxId, "read", position); log.info({ mailbox_id: inboxId, filter: "read", position, limit: 50, returned: result.emails.length, total: result.total, duration_ms: Date.now() - t }, "action.load_more"); return result; @@ -43,12 +48,11 @@ export async function loadMoreReads( export async function searchEmailsAction(query: string): Promise { const t = Date.now(); - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await requireAuthedJmap(); const parsed = parseSearchQuery(query); const filter = buildJmapFilter(parsed); const results = await searchEmails(session.apiUrl, accountId, filter); - log.info({ query, filter, results: results.length, duration_ms: Date.now() - t }, "action.search"); + log.info({ query_len: query.length, results: results.length, duration_ms: Date.now() - t }, "action.search"); return results; } @@ -57,36 +61,32 @@ export async function togglePinAction( pin: boolean ): Promise { const t = Date.now(); - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await requireAuthedJmap(); await setPin(session.apiUrl, accountId, emailId, pin); log.info({ email_id: emailId, pin, duration_ms: Date.now() - t }, "action.toggle_pin"); } export async function bulkMarkAsRead(emailIds: string[]): Promise { const t = Date.now(); - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await requireAuthedJmap(); await setKeywordsOnMany(session.apiUrl, accountId, emailIds, { "keywords/$seen": true }); - log.info({ email_ids: emailIds, count: emailIds.length, duration_ms: Date.now() - t }, "action.mark_read"); + log.info({ count: emailIds.length, duration_ms: Date.now() - t }, "action.mark_read"); } export async function bulkMarkAsUnread(emailIds: string[]): Promise { const t = Date.now(); - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await requireAuthedJmap(); await setKeywordsOnMany(session.apiUrl, accountId, emailIds, { "keywords/$seen": null }); - log.info({ email_ids: emailIds, count: emailIds.length, duration_ms: Date.now() - t }, "action.mark_unread"); + log.info({ count: emailIds.length, duration_ms: Date.now() - t }, "action.mark_unread"); } export async function bulkSetPin(emailIds: string[], pin: boolean): Promise { const t = Date.now(); - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await requireAuthedJmap(); await setKeywordsOnMany(session.apiUrl, accountId, emailIds, { "keywords/$flagged": pin ? true : null, }); - log.info({ email_ids: emailIds, count: emailIds.length, pin, duration_ms: Date.now() - t }, "action.bulk_pin"); + log.info({ count: emailIds.length, pin, duration_ms: Date.now() - t }, "action.bulk_pin"); } export async function bulkMoveToMailbox( @@ -94,8 +94,11 @@ export async function bulkMoveToMailbox( targetMailboxId: string ): Promise { const t = Date.now(); - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await requireAuthedJmap(); + const mailboxes = await getMailboxes(session.apiUrl, accountId); + if (!mailboxes.some((mailbox) => mailbox.id === targetMailboxId)) { + throw new Error("Invalid mailbox"); + } await moveEmailsToMailbox(session.apiUrl, accountId, emails, targetMailboxId); - log.info({ email_ids: emails.map((e) => e.id), count: emails.length, target_mailbox_id: targetMailboxId, duration_ms: Date.now() - t }, "action.move_emails"); + log.info({ count: emails.length, target_mailbox_id: targetMailboxId, duration_ms: Date.now() - t }, "action.move_emails"); } diff --git a/src/app/(inbox)/email/[id]/actions.ts b/src/app/(inbox)/email/[id]/actions.ts index d9311e7..293e9b3 100644 --- a/src/app/(inbox)/email/[id]/actions.ts +++ b/src/app/(inbox)/email/[id]/actions.ts @@ -1,21 +1,27 @@ "use server"; +import { auth } from "@/auth"; import { getSession, getAccountId, getIdentities, getMailboxes, markAsRead, markAsUnread, sendCalendarReply, setKeywordsOnMany } from "@/lib/jmap"; import { parseIcs, buildCalendarReply } from "@/lib/ics"; import { log } from "@/lib/logger"; +async function requireAuthedJmap() { + const sessionData = await auth(); + if (!sessionData?.user) throw new Error("Unauthorized"); + const session = await getSession(); + return { session, accountId: getAccountId(session) }; +} + export async function markEmailAsRead(emailId: string): Promise { const t = Date.now(); - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await requireAuthedJmap(); await markAsRead(session.apiUrl, accountId, emailId); log.info({ email_id: emailId, duration_ms: Date.now() - t }, "action.mark_read"); } export async function markEmailAsUnread(emailId: string): Promise { const t = Date.now(); - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await requireAuthedJmap(); await markAsUnread(session.apiUrl, accountId, emailId); log.info({ email_id: emailId, duration_ms: Date.now() - t }, "action.mark_unread"); } @@ -27,8 +33,7 @@ export async function sendCalendarReplyAction( inReplyToMessageId?: string ): Promise { const t = Date.now(); - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await requireAuthedJmap(); const [identities, mailboxes] = await Promise.all([ getIdentities(session.apiUrl, accountId), getMailboxes(session.apiUrl, accountId), @@ -80,11 +85,8 @@ export async function sendCalendarReplyAction( log.info({ response, - event_summary: event.summary, - event_start: event.dtStart, - organizer: to.email, - from: identity.email, - in_reply_to_message_id: inReplyToMessageId, + has_organizer: !!event.organizer?.email, + has_in_reply_to_message_id: !!inReplyToMessageId, duration_ms: Date.now() - t, }, "action.calendar_reply"); } diff --git a/src/app/(inbox)/settings/actions.ts b/src/app/(inbox)/settings/actions.ts index dccf08d..65e7d21 100644 --- a/src/app/(inbox)/settings/actions.ts +++ b/src/app/(inbox)/settings/actions.ts @@ -1,11 +1,14 @@ "use server"; +import { auth } from "@/auth"; import { getSession, getAccountId, getIdentities, updateIdentitySignature } from "@/lib/jmap"; import { revalidatePath } from "next/cache"; import { log } from "@/lib/logger"; export async function saveSignatureAction(signature: string): Promise { const t = Date.now(); + const sessionData = await auth(); + if (!sessionData?.user) throw new Error("Unauthorized"); const session = await getSession(); const accountId = getAccountId(session); const identities = await getIdentities(session.apiUrl, accountId); diff --git a/src/app/api/contacts/route.ts b/src/app/api/contacts/route.ts index d26fb2e..49c5eec 100644 --- a/src/app/api/contacts/route.ts +++ b/src/app/api/contacts/route.ts @@ -1,7 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; import { getSession, getAccountId, searchContacts } from "@/lib/jmap"; export async function GET(req: NextRequest) { + const sessionData = await auth(); + if (!sessionData?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const q = req.nextUrl.searchParams.get("q") ?? ""; if (q.trim().length < 2) return NextResponse.json([]); diff --git a/src/app/api/download/route.ts b/src/app/api/download/route.ts index ef25f31..18ea715 100644 --- a/src/app/api/download/route.ts +++ b/src/app/api/download/route.ts @@ -1,14 +1,51 @@ import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; import { getSession, getAccountId } from "@/lib/jmap"; import { log } from "@/lib/logger"; +const MIME_RE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i; + +function normalizeMimeType(value: string | null | undefined): string | null { + if (!value) return null; + const [mimeType] = value.split(";", 1); + const normalized = mimeType.trim().toLowerCase(); + return MIME_RE.test(normalized) ? normalized : null; +} + +function sanitizeFilename(name: string): string { + const sanitized = name + .replace(/[\u0000-\u001f\u007f]/g, "") + .replace(/[\\/:"*?<>|]+/g, "_") + .trim(); + return sanitized || "file"; +} + +function formatContentDisposition(disposition: "attachment" | "inline", filename: string): string { + const asciiFallback = filename + .replace(/[^\x20-\x7e]/g, "_") + .replace(/["\\]/g, "_"); + return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`; +} + +function canInline(contentType: string): boolean { + return ( + contentType === "application/pdf" || + (contentType.startsWith("image/") && contentType !== "image/svg+xml") + ); +} + export async function GET(req: NextRequest) { const t = Date.now(); try { + const sessionData = await auth(); + if (!sessionData?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { searchParams } = new URL(req.url); const blobId = searchParams.get("blobId"); - const name = searchParams.get("name") ?? "file"; - const type = searchParams.get("type") ?? "application/octet-stream"; + const name = sanitizeFilename(searchParams.get("name") ?? "file"); + const type = normalizeMimeType(searchParams.get("type")) ?? "application/octet-stream"; if (!blobId) { return NextResponse.json({ error: "Missing blobId" }, { status: 400 }); @@ -22,7 +59,7 @@ export async function GET(req: NextRequest) { const url = session.downloadUrl .replace(/\{accountId\}/, accountId) - .replace(/\{blobId\}/, blobId) + .replace(/\{blobId\}/, encodeURIComponent(blobId)) .replace(/\{name\}/, encodeURIComponent(name)) .replace(/\{type\}/, encodeURIComponent(type)); @@ -36,21 +73,29 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "Download failed" }, { status: res.status }); } - log.info({ blob_id: blobId, name, type, duration_ms: Date.now() - t }, "route.download"); - + const contentType = normalizeMimeType(res.headers.get("Content-Type")) ?? type; const inline = searchParams.get("inline") === "true"; - const disposition = inline ? "inline" : "attachment"; + const disposition = inline && canInline(contentType) ? "inline" : "attachment"; const headers: Record = { - "Content-Type": type, - "Content-Disposition": `${disposition}; filename="${name.replace(/"/g, '\\"')}"`, + "Content-Type": contentType, + "Content-Disposition": formatContentDisposition(disposition, name), + "Cross-Origin-Resource-Policy": "same-origin", + "X-Content-Type-Options": "nosniff", }; const contentLength = res.headers.get("Content-Length"); if (contentLength) headers["Content-Length"] = contentLength; + log.info({ + blob_id: blobId, + content_type: contentType, + inline: disposition === "inline", + duration_ms: Date.now() - t, + }, "route.download"); + return new Response(res.body, { headers }); } catch (e) { const message = e instanceof Error ? e.message : "Unknown error"; log.error({ err: message, duration_ms: Date.now() - t }, "route.download.error"); - return NextResponse.json({ error: message }, { status: 500 }); + return NextResponse.json({ error: "Download failed" }, { status: 500 }); } } diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index 29c0da0..add7b1c 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -1,15 +1,75 @@ import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; import { getSession, getAccountId, getIdentities, getMailboxes, sendEmail } from "@/lib/jmap"; import { log } from "@/lib/logger"; +function getString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function getOptionalString(value: unknown): string | undefined { + const normalized = getString(value).trim(); + return normalized || undefined; +} + +function getStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function getInlineImages(value: unknown): { id: string; blobId: string; type: string }[] | undefined { + if (!Array.isArray(value)) return undefined; + return value + .map((entry) => { + if (!entry || typeof entry !== "object") return null; + const candidate = entry as Record; + const id = getOptionalString(candidate.id); + const blobId = getOptionalString(candidate.blobId); + const type = getOptionalString(candidate.type); + return id && blobId && type ? { id, blobId, type } : null; + }) + .filter((entry): entry is { id: string; blobId: string; type: string } => entry !== null); +} + +function getAttachments(value: unknown): { blobId: string; name: string; type: string }[] | undefined { + if (!Array.isArray(value)) return undefined; + return value + .map((entry) => { + if (!entry || typeof entry !== "object") return null; + const candidate = entry as Record; + const blobId = getOptionalString(candidate.blobId); + const name = getOptionalString(candidate.name); + const type = getOptionalString(candidate.type); + return blobId && name && type ? { blobId, name, type } : null; + }) + .filter((entry): entry is { blobId: string; name: string; type: string } => entry !== null); +} + export async function POST(req: NextRequest) { const t = Date.now(); try { - const body = await req.json(); - const { identityId, to, cc, bcc, subject, textBody, htmlBody, inlineImages, attachments, inReplyToId } = body; + const sessionData = await auth(); + if (!sessionData?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } - if (!identityId || !to?.length || !subject || !textBody) { - log.warn({ identityId: !!identityId, to_count: to?.length ?? 0, has_subject: !!subject, has_body: !!textBody }, "route.send.bad_request"); + const body = await req.json() as Record; + const identityId = getString(body.identityId); + const to = getStringArray(body.to); + const cc = getStringArray(body.cc); + const bcc = getStringArray(body.bcc); + const subject = getString(body.subject); + const textBody = getString(body.textBody); + const htmlBody = getString(body.htmlBody); + const inlineImages = getInlineImages(body.inlineImages); + const attachments = getAttachments(body.attachments); + const inReplyToId = getOptionalString(body.inReplyToId); + + if (!identityId || !to.length || !subject || !textBody) { + log.warn({ identityId: !!identityId, to_count: to.length, has_subject: !!subject, has_body: !!textBody }, "route.send.bad_request"); return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } @@ -44,18 +104,15 @@ export async function POST(req: NextRequest) { }); log.info({ - from: identity.email, - to, to_count: to.length, - cc_count: cc?.length ?? 0, - bcc_count: bcc?.length ?? 0, - subject, + cc_count: cc.length, + bcc_count: bcc.length, + subject_len: subject.length, text_len: textBody.length, - html_len: htmlBody?.length ?? 0, + html_len: htmlBody.length, inline_image_count: inlineImages?.length ?? 0, attachment_count: attachments?.length ?? 0, is_reply: !!inReplyToId, - in_reply_to_id: inReplyToId, email_id: result.emailId, submission_id: result.submissionId, duration_ms: Date.now() - t, @@ -65,6 +122,6 @@ export async function POST(req: NextRequest) { } catch (e) { const message = e instanceof Error ? e.message : "Unknown error"; log.error({ err: message, duration_ms: Date.now() - t }, "route.send.error"); - return NextResponse.json({ error: message }, { status: 500 }); + return NextResponse.json({ error: "Send failed" }, { status: 500 }); } } diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 44dee16..ce6994a 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,10 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; import { getSession, getAccountId, uploadBlob } from "@/lib/jmap"; import { log } from "@/lib/logger"; export async function POST(req: NextRequest) { const t = Date.now(); try { + const sessionData = await auth(); + if (!sessionData?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const formData = await req.formData(); const file = formData.get("file"); @@ -19,7 +25,6 @@ export async function POST(req: NextRequest) { const result = await uploadBlob(session.uploadUrl, accountId, buffer, file.type); log.info({ - file_name: file.name, file_type: file.type, file_size_bytes: buffer.byteLength, blob_id: result.blobId, @@ -30,6 +35,6 @@ export async function POST(req: NextRequest) { } catch (e) { const message = e instanceof Error ? e.message : "Unknown error"; log.error({ err: message, duration_ms: Date.now() - t }, "route.upload.error"); - return NextResponse.json({ error: message }, { status: 500 }); + return NextResponse.json({ error: "Upload failed" }, { status: 500 }); } } diff --git a/src/app/compose/actions.ts b/src/app/compose/actions.ts index a0e1a27..977edde 100644 --- a/src/app/compose/actions.ts +++ b/src/app/compose/actions.ts @@ -1,8 +1,10 @@ "use server"; +import { auth } from "@/auth"; import { getSession, getAccountId, + getIdentities, getMailboxes, saveDraft, deleteDraft, @@ -32,11 +34,18 @@ export async function saveDraftAction( input: DraftSaveInput ): Promise<{ draftId: string }> { const t = Date.now(); + const sessionData = await auth(); + if (!sessionData?.user) throw new Error("Unauthorized"); const session = await getSession(); const accountId = getAccountId(session); - const mailboxes = await getMailboxes(session.apiUrl, accountId); + const [mailboxes, identities] = await Promise.all([ + getMailboxes(session.apiUrl, accountId), + getIdentities(session.apiUrl, accountId), + ]); const draftsMailbox = mailboxes.find((m) => m.role === "drafts"); if (!draftsMailbox) throw new Error("No drafts mailbox found"); + const identity = identities.find((candidate) => candidate.email === input.fromEmail); + if (!identity) throw new Error("Invalid from address"); const toAddrs = parseAddresses(splitRaw(input.to)); const ccAddrs = parseAddresses(splitRaw(input.cc)); @@ -47,7 +56,7 @@ export async function saveDraftAction( accountId, draftsMailbox.id, { - from: { name: input.fromName, email: input.fromEmail }, + from: { name: identity.name, email: identity.email }, to: toAddrs, cc: ccAddrs, bcc: bccAddrs, @@ -61,12 +70,10 @@ export async function saveDraftAction( is_update: !!input.draftId, prev_draft_id: input.draftId ?? undefined, new_draft_id: draftId, - from: input.fromEmail, - to: toAddrs.map((a) => a.email), to_count: toAddrs.length, cc_count: ccAddrs.length, bcc_count: bccAddrs.length, - subject: input.subject, + subject_len: input.subject.length, body_len: input.body.length, duration_ms: Date.now() - t, }, "action.save_draft"); @@ -76,6 +83,8 @@ export async function saveDraftAction( export async function deleteDraftAction(draftId: string): Promise { const t = Date.now(); + const sessionData = await auth(); + if (!sessionData?.user) throw new Error("Unauthorized"); const session = await getSession(); const accountId = getAccountId(session); await deleteDraft(session.apiUrl, accountId, draftId); diff --git a/src/components/EmailBody.tsx b/src/components/EmailBody.tsx index 3fad073..1b54015 100644 --- a/src/components/EmailBody.tsx +++ b/src/components/EmailBody.tsx @@ -27,6 +27,7 @@ export default function EmailBody({ body, type, stripQuotes }: Props) { const wrapper = wrapperRef.current; if (!iframe || !wrapper) return; if (e.source !== iframe.contentWindow) return; + if (e.origin !== "null") return; if (e.data?.type !== "iframe-resize") return; const naturalH: number = e.data.height; @@ -74,7 +75,7 @@ export default function EmailBody({ body, type, stripQuotes }: Props) { ref={iframeRef} srcDoc={srcDoc} className="w-full border-0 block" - sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" + sandbox="allow-scripts allow-popups" title="Email content" /> diff --git a/src/lib/__tests__/jmap.test.ts b/src/lib/__tests__/jmap.test.ts index f0ece4d..0bbf798 100644 --- a/src/lib/__tests__/jmap.test.ts +++ b/src/lib/__tests__/jmap.test.ts @@ -2,7 +2,7 @@ process.env.FASTMAIL_API_TOKEN = "test-token"; import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { getAccountId, getUnreadInboxTotal, listInboxEmails, loadMoreEmailsFiltered, setKeywordsOnMany, moveEmailsToMailbox, sendEmail, deleteDraft } from "../jmap"; +import { deleteDraft, getAccountId, getUnreadInboxTotal, listInboxEmails, loadMoreEmailsFiltered, moveEmailsToMailbox, parseAddresses, sendEmail, setKeywordsOnMany } from "../jmap"; const MAIL_CAP = "urn:ietf:params:jmap:mail"; @@ -64,6 +64,19 @@ describe("getAccountId", () => { }); }); +describe("parseAddresses", () => { + it("parses display names and bare addresses", () => { + assert.deepEqual(parseAddresses(["Alice Example ", "bob@example.com"]), [ + { name: "Alice Example", email: "alice@example.com" }, + { name: null, email: "bob@example.com" }, + ]); + }); + + it("throws on invalid email addresses", () => { + assert.throws(() => parseAddresses(["definitely not an email"]), /Invalid email address/); + }); +}); + // --------------------------------------------------------------------------- // listInboxEmails // --------------------------------------------------------------------------- diff --git a/src/lib/__tests__/printHtml.test.ts b/src/lib/__tests__/printHtml.test.ts index aa39508..935f475 100644 --- a/src/lib/__tests__/printHtml.test.ts +++ b/src/lib/__tests__/printHtml.test.ts @@ -1,6 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { sanitizeHtml, extractStyles, extractBodyContent, resolvePrintBody } from "../printHtml"; +import type { Email } from "../types"; // --------------------------------------------------------------------------- // sanitizeHtml @@ -49,27 +50,35 @@ describe("sanitizeHtml", () => { it("rewrites javascript: href to #", () => { const result = sanitizeHtml('click'); assert.ok(!result.includes("javascript:"), "javascript: URI should be removed"); - assert.ok(result.includes('href="#"'), "href should be replaced with #"); + assert.ok(!result.includes('href="javascript:void(0)"'), "unsafe href should be removed"); }); - it("rewrites javascript: in src attributes", () => { + it("removes blocked iframe tags entirely", () => { const result = sanitizeHtml(''); - assert.ok(!result.includes("javascript:")); + assert.ok(!result.includes(" { + const result = sanitizeHtml(""); + assert.ok(!result.includes("onerror")); + assert.ok(!result.includes("alert(1)")); }); it("leaves normal https links untouched", () => { const input = 'link'; - assert.equal(sanitizeHtml(input), input); + assert.ok(sanitizeHtml(input).includes(input)); }); - it("preserves style tags", () => { + it("removes style tags from the sanitized body HTML", () => { const input = "

text

"; - assert.ok(sanitizeHtml(input).includes("body { color: red; }")); + const result = sanitizeHtml(input); + assert.ok(!result.includes(""); + assert.ok(!result.includes("@import")); + assert.ok(!result.includes("url(")); + assert.ok(!result.includes("expression(")); + }); }); // --------------------------------------------------------------------------- @@ -190,7 +206,7 @@ describe("resolvePrintBody", () => { size: 0, messageId: null, ...overrides, - } as any; + } as unknown as Email; } it("resolves html bodyType from htmlBody part", () => { @@ -240,6 +256,34 @@ describe("resolvePrintBody", () => { assert.ok(!bodyHtml.includes("onclick")); }); + it("sanitizes unquoted event handlers out of htmlBody", () => { + const email = makeEmail({ + htmlBody: [{ partId: "p1", type: "text/html" }], + bodyValues: { p1: { value: "", charset: "utf-8", isEncodingProblem: false, isTruncated: false } }, + }); + const { bodyHtml } = resolvePrintBody(email); + assert.ok(!bodyHtml.includes("onerror")); + assert.ok(!bodyHtml.includes("alert(1)")); + }); + + it("drops dangerous CSS while preserving printable HTML", () => { + const email = makeEmail({ + htmlBody: [{ partId: "p1", type: "text/html" }], + bodyValues: { + p1: { + value: '

safe

', + charset: "utf-8", + isEncodingProblem: false, + isTruncated: false, + }, + }, + }); + const { bodyHtml, emailStyles } = resolvePrintBody(email); + assert.ok(bodyHtml.includes(" { const email = makeEmail({ textBody: [{ partId: "p1", type: "text/plain" }], diff --git a/src/lib/jmap.ts b/src/lib/jmap.ts index 26fd237..a9b6090 100644 --- a/src/lib/jmap.ts +++ b/src/lib/jmap.ts @@ -650,9 +650,15 @@ export interface InlineImage { export function parseAddresses(addrs: string[]): { name: string | null; email: string }[] { return addrs.map((addr) => { + const validateEmail = (value: string) => { + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + throw new Error(`Invalid email address: ${value}`); + } + return value; + }; const m = addr.match(/^(.+?)\s*<(.+?)>$/); - if (m) return { name: m[1].trim(), email: m[2].trim() }; - return { name: null, email: addr.trim() }; + if (m) return { name: m[1].trim(), email: validateEmail(m[2].trim()) }; + return { name: null, email: validateEmail(addr.trim()) }; }); } @@ -925,7 +931,7 @@ export async function searchContacts( const duration_ms = Date.now() - t; if (!res.ok) { - log.warn({ query, http_status: res.status, duration_ms }, "jmap.contacts.error"); + log.warn({ query_len: query.length, http_status: res.status, duration_ms }, "jmap.contacts.error"); return []; } @@ -941,6 +947,6 @@ export async function searchContacts( } } - log.info({ query, results: results.length, duration_ms }, "jmap.contacts"); + log.info({ query_len: query.length, results: results.length, duration_ms }, "jmap.contacts"); return results; } diff --git a/src/lib/printHtml.ts b/src/lib/printHtml.ts index e2d0f4f..4c60c1a 100644 --- a/src/lib/printHtml.ts +++ b/src/lib/printHtml.ts @@ -1,15 +1,117 @@ +import { JSDOM } from "jsdom"; import { Email } from "@/lib/types"; +const BLOCKED_TAGS = [ + "applet", + "base", + "button", + "embed", + "form", + "frame", + "frameset", + "iframe", + "input", + "link", + "meta", + "noscript", + "object", + "script", + "select", + "source", + "style", + "textarea", +]; + +const URL_ATTRIBUTES = new Set([ + "action", + "formaction", + "href", + "poster", + "src", + "xlink:href", +]); + +const SAFE_DATA_URL_RE = /^data:image\/(?:bmp|gif|jpeg|jpg|png|webp);base64,[a-z0-9+/=\s]+$/i; + +function sanitizeCss(css: string): string { + return css + .replace(/@import[\s\S]*?;/gi, "") + .replace(/url\s*\((?:[^)(]|\([^)(]*\))*\)/gi, "") + .replace(/expression\s*\([^)]*\)/gi, "") + .replace(/behavior\s*:[^;"}]+;?/gi, "") + .replace(/-moz-binding\s*:[^;"}]+;?/gi, "") + .trim(); +} + +function sanitizeUrl(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return ""; + if ( + trimmed.startsWith("#") || + trimmed.startsWith("/") || + trimmed.startsWith("./") || + trimmed.startsWith("../") + ) { + return trimmed; + } + if (trimmed.startsWith("cid:")) return trimmed; + if (trimmed.startsWith("mailto:") || trimmed.startsWith("tel:")) return trimmed; + if (/^data:/i.test(trimmed)) { + return SAFE_DATA_URL_RE.test(trimmed) ? trimmed : null; + } + + try { + const hasExplicitScheme = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed); + const parsed = new URL(trimmed, "https://email.invalid"); + if (!hasExplicitScheme && !trimmed.startsWith("//")) return trimmed; + return parsed.protocol === "http:" || parsed.protocol === "https:" ? trimmed : null; + } catch { + return null; + } +} + /** - * Server-side HTML sanitizer — no DOM required. - * Strips scripts and event handlers; keeps styles and layout intact. + * Server-side HTML sanitizer for print views. + * Parses untrusted email HTML, removes active content, and strips dangerous URL/CSS vectors. */ export function sanitizeHtml(html: string): string { - return html - .replace(/]*>[\s\S]*?<\/script>/gi, "") - .replace(/\s+on[a-zA-Z]+\s*=\s*"[^"]*"/gi, "") - .replace(/\s+on[a-zA-Z]+\s*=\s*'[^']*'/gi, "") - .replace(/(href|src|action)\s*=\s*["']?\s*javascript:[^"'\s>]*/gi, "$1=\"#\""); + const dom = new JSDOM(html); + const { document } = dom.window; + + for (const tag of BLOCKED_TAGS) { + document.querySelectorAll(tag).forEach((element) => element.remove()); + } + + document.querySelectorAll("*").forEach((element) => { + for (const attr of Array.from(element.attributes)) { + const name = attr.name.toLowerCase(); + if (name.startsWith("on") || name === "srcdoc" || name === "srcset") { + element.removeAttribute(attr.name); + continue; + } + + if (name === "style") { + const sanitizedStyle = sanitizeCss(attr.value); + if (sanitizedStyle) { + element.setAttribute("style", sanitizedStyle); + } else { + element.removeAttribute(attr.name); + } + continue; + } + + if (URL_ATTRIBUTES.has(name)) { + const sanitizedUrl = sanitizeUrl(attr.value); + if (sanitizedUrl === null) { + element.removeAttribute(attr.name); + } else { + element.setAttribute(attr.name, sanitizedUrl); + } + } + } + }); + + return document.documentElement.outerHTML; } export function extractStyles(html: string): string { @@ -17,7 +119,7 @@ export function extractStyles(html: string): string { const re = /]*>([\s\S]*?)<\/style>/gi; let m; while ((m = re.exec(html)) !== null) out.push(m[1]); - return out.join("\n"); + return sanitizeCss(out.join("\n")); } export function extractBodyContent(html: string): string { @@ -58,7 +160,7 @@ export function resolvePrintBody(email: Email): { const sanitized = sanitizeHtml(rawBody); return { bodyHtml: extractBodyContent(sanitized), - emailStyles: extractStyles(sanitized), + emailStyles: extractStyles(rawBody), bodyType, }; }