diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 53417a16ed..ef4441dde9 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -385,6 +385,9 @@ importers: '@rush-temp/kanban': specifier: file:./projects/kanban.tgz version: file:projects/kanban.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@20.11.19)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)))(postcss@8.5.3)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) + '@rush-temp/kvs-client': + specifier: file:./projects/kvs-client.tgz + version: file:projects/kvs-client.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) '@rush-temp/lead': specifier: file:./projects/lead.tgz version: file:projects/lead.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@20.11.19)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) @@ -771,7 +774,7 @@ importers: version: file:projects/pod-github.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(utf-8-validate@6.0.4)(y-prosemirror@1.2.15(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23)) '@rush-temp/pod-gmail': specifier: file:./projects/pod-gmail.tgz - version: file:projects/pod-gmail.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) + version: file:projects/pod-gmail.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3) '@rush-temp/pod-inbound-mail': specifier: file:./projects/pod-inbound-mail.tgz version: file:projects/pod-inbound-mail.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9)) @@ -1870,6 +1873,9 @@ importers: jest-environment-jsdom: specifier: ^29.7.0 version: 29.7.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) + jest-fetch-mock: + specifier: ^3.0.3 + version: 3.0.3(encoding@0.1.13) jimp: specifier: ^0.16.1 version: 0.16.13 @@ -1942,6 +1948,9 @@ importers: mongodb: specifier: ^6.12.0 version: 6.12.0(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3) + mongodb-memory-server: + specifier: ^10.1.4 + version: 10.1.4(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3) morgan: specifier: ^1.10.0 version: 1.10.0 @@ -4313,6 +4322,10 @@ packages: resolution: {integrity: sha512-a1aAx7zBxeZjaejI86Ho69hHcEm7F4dkdD9rVCv2BVHzjG/1C8mq7pv+QWHFGoxkiQwwm9MatVV4IMCSjDjAZw==, tarball: file:projects/kanban.tgz} version: 0.0.0 + '@rush-temp/kvs-client@file:projects/kvs-client.tgz': + resolution: {integrity: sha512-UHZpGqCKncG8BGVHJPeceusj86Knh9SjhNvp972sLvA/FY89ph6Rpz+34X6aI3D5Cu3048ePmwszH81PKphNrQ==, tarball: file:projects/kvs-client.tgz} + version: 0.0.0 + '@rush-temp/lead-assets@file:projects/lead-assets.tgz': resolution: {integrity: sha512-VVUGNaGtZaGVHj7V/o6b0kmXeLwYxQA3LwuWvW0vbv2xdMsfZO/QC6ShBZPUSrzYPjfOWTjvS6lhH2alTanW7A==, tarball: file:projects/lead-assets.tgz} version: 0.0.0 @@ -4826,7 +4839,7 @@ packages: version: 0.0.0 '@rush-temp/pod-gmail@file:projects/pod-gmail.tgz': - resolution: {integrity: sha512-HyyHadfmgT1OP7pdln0WJvK6cGPTYVVvxq21JBHKshu40WojCB6dLdAch/YrTO0aSS7UzluuLmYnnCn5JGvfcg==, tarball: file:projects/pod-gmail.tgz} + resolution: {integrity: sha512-mWpD7TVaoiHBcGFgqt2KoPioV7Uc/9Y7DXu3LKiwZBrUX6X+WR/77RcKq/gNGPbwNxCKOc+dDsscF5NKMhIqSg==, tarball: file:projects/pod-gmail.tgz} version: 0.0.0 '@rush-temp/pod-inbound-mail@file:projects/pod-inbound-mail.tgz': @@ -5482,7 +5495,7 @@ packages: version: 0.0.0 '@rush-temp/tool@file:projects/tool.tgz': - resolution: {integrity: sha512-zqh0++u5ETaUNlwkipP+8CeICqrFYEDBO4sAjFRyLPd1SHZbanUpQRAg58t4C+Z7q4Owqr149ee/hCGWPult3w==, tarball: file:projects/tool.tgz} + resolution: {integrity: sha512-k0KXvAT1ulcbCo/O0bYbPxjZHX2I7BAGMr7Ntdrt1xLcUBsQ3WSOUw9AxCLU4+PiJmIHQVpFEhJPNc06CeFfCA==, tarball: file:projects/tool.tgz} version: 0.0.0 '@rush-temp/tracker-assets@file:projects/tracker-assets.tgz': @@ -5510,7 +5523,7 @@ packages: version: 0.0.0 '@rush-temp/ui-next@file:projects/ui-next.tgz': - resolution: {integrity: sha512-7yi52BTXL9htk9CapPOLg7a26v5GVVYSKPWqEbWrEQgin1JvjL7F7qv3EcIGVtdcCnfkvATeDXT8HLvZNg9DwA==, tarball: file:projects/ui-next.tgz} + resolution: {integrity: sha512-bisoYF6n3HWSicnaOnc1DhInOtgSsIW77QjtWRtuq3lmJMzKQ8u+RF2wqa6gUaw3Qa/qcbE+HyzkJGQyCVB0og==, tarball: file:projects/ui-next.tgz} version: 0.0.0 '@rush-temp/ui@file:projects/ui.tgz': @@ -7137,6 +7150,9 @@ packages: async-mutex@0.3.2: resolution: {integrity: sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==} + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + async@3.2.5: resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} @@ -7644,6 +7660,9 @@ packages: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + compare-version@0.1.2: resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} engines: {node: '>=0.10.0'} @@ -9064,6 +9083,10 @@ packages: resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} engines: {node: '>= 0.8'} + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + find-up@3.0.0: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} @@ -9112,6 +9135,15 @@ packages: debug: optional: true + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -10049,6 +10081,9 @@ packages: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-fetch-mock@3.0.3: + resolution: {integrity: sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==} + jest-get-type@29.6.3: resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -10651,6 +10686,10 @@ packages: resolution: {integrity: sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==} engines: {node: '>=12'} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -10903,6 +10942,14 @@ packages: mongodb-connection-string-url@3.0.0: resolution: {integrity: sha512-t1Vf+m1I5hC2M5RJx/7AtxgABy1cZmIPQRMXw+gEIPn/cZNF3Oiy+l0UIypUwVB5trcWHq3crg2g3uAR9aAwsQ==} + mongodb-memory-server-core@10.1.4: + resolution: {integrity: sha512-o8fgY7ZalEd8pGps43fFPr/hkQu1L8i6HFEGbsTfA2zDOW0TopgpswaBCqDr0qD7ptibyPfB5DmC+UlIxbThzA==} + engines: {node: '>=16.20.1'} + + mongodb-memory-server@10.1.4: + resolution: {integrity: sha512-+oKQ/kc3CX+816oPFRtaF0CN4vNcGKNjpOQe4bHo/21A3pMD+lC7Xz1EX5HP7siCX4iCpVchDMmCOFXVQSGkUg==} + engines: {node: '>=16.20.1'} + mongodb@6.12.0: resolution: {integrity: sha512-RM7AHlvYfS7jv7+BXund/kR64DryVI+cHbVAy9P61fnb1RcWZqOW1/Wj2YhqMCx+MuYhqTRGv7AwHBzmsCKBfA==} engines: {node: '>=16.20.1'} @@ -10997,6 +11044,10 @@ packages: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} + new-find-package-json@2.0.0: + resolution: {integrity: sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==} + engines: {node: '>=12.22.0'} + next-tick@1.1.0: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} @@ -11801,6 +11852,9 @@ packages: promise-inflight@1.0.1: resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} + promise-polyfill@8.3.0: + resolution: {integrity: sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==} + promise-retry@2.0.1: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} @@ -12214,6 +12268,9 @@ packages: sanitize-html@2.15.0: resolution: {integrity: sha512-wIjst57vJGpLyBP8ioUbg6ThwJie5SuSIjHxJg53v5Fg+kUK+AXlb7bK3RNXpp315MvwM+0OBGCV6h5pPHsVhA==} + sanitize-html@2.16.0: + resolution: {integrity: sha512-0s4caLuHHaZFVxFTG74oW91+j6vW7gKbGD6CD2+miP73CE6z6YtOBN0ArtLd2UGyi4IC7K47v3ENUbQX4jV3Mg==} + sass-loader@13.3.3: resolution: {integrity: sha512-mt5YN2F1MOZr3d/wBRcZxeFgwgkH44wVc2zohO2YF6JiOMkiXe4BYRZpSu2sO1g71mo/j16txzUhsKZlqjVGzA==} engines: {node: '>= 14.15.0'} @@ -13713,6 +13770,10 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yauzl@3.2.0: + resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==} + engines: {node: '>=12'} + yjs@13.6.23: resolution: {integrity: sha512-ExtnT5WIOVpkL56bhLeisG/N5c4fmzKn4k0ROVfJa5TY2QHbH7F0Wu2T5ZhR7ErsFWQEFafyrnSI8TPKVF9Few==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -19372,6 +19433,34 @@ snapshots: - supports-color - ts-node + '@rush-temp/kvs-client@file:projects/kvs-client.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))': + dependencies: + '@types/jest': 29.5.12 + '@types/node': 20.11.19 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) + cross-env: 7.0.3 + esbuild: 0.24.2 + eslint: 8.56.0 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint-plugin-n@15.7.0(eslint@8.56.0))(eslint-plugin-promise@6.1.1(eslint@8.56.0))(eslint@8.56.0)(typescript@5.7.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) + jest-fetch-mock: 3.0.3(encoding@0.1.13) + prettier: 3.2.5 + ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)))(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - '@babel/core' + - '@jest/types' + - babel-jest + - babel-plugin-macros + - encoding + - node-notifier + - supports-color + - ts-node + '@rush-temp/lead-assets@file:projects/lead-assets.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))': dependencies: '@types/jest': 29.5.12 @@ -22053,17 +22142,19 @@ snapshots: - utf-8-validate - y-prosemirror - '@rush-temp/pod-gmail@file:projects/pod-gmail.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))': + '@rush-temp/pod-gmail@file:projects/pod-gmail.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)': dependencies: '@tsconfig/node16': 1.0.4 '@types/cors': 2.8.17 '@types/express': 4.17.21 '@types/jest': 29.5.12 '@types/node': 20.11.19 + '@types/sanitize-html': 2.15.0 '@types/uuid': 8.3.4 '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) cors: 2.8.5 + cross-env: 7.0.3 dotenv: 16.0.3 esbuild: 0.24.2 eslint: 8.56.0 @@ -22082,7 +22173,9 @@ snapshots: jwt-simple: 0.5.6 mongodb: 6.12.0(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3) prettier: 3.2.5 + sanitize-html: 2.16.0 ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)))(typescript@5.3.3) + ts-node: 10.9.2(@types/node@20.11.19)(typescript@5.3.3) ts-node-dev: 2.0.0(@types/node@20.11.19)(typescript@5.3.3) typescript: 5.3.3 uuid: 8.3.2 @@ -22103,7 +22196,6 @@ snapshots: - snappy - socks - supports-color - - ts-node '@rush-temp/pod-inbound-mail@file:projects/pod-inbound-mail.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))': dependencies: @@ -26977,6 +27069,7 @@ snapshots: libphonenumber-js: 1.10.56 mime-types: 2.1.35 mongodb: 6.12.0(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3) + mongodb-memory-server: 10.1.4(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3) msgpackr: 1.11.2 msgpackr-extract: 3.0.3 postgres: 3.4.5 @@ -29730,6 +29823,10 @@ snapshots: dependencies: tslib: 2.7.0 + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + async@3.2.5: {} asynckit@0.4.0: {} @@ -30326,6 +30423,8 @@ snapshots: commander@9.5.0: {} + commondir@1.0.1: {} + compare-version@0.1.2: {} component-emitter@1.3.1: {} @@ -32105,6 +32204,12 @@ snapshots: statuses: 2.0.1 unpipe: 1.0.0 + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + find-up@3.0.0: dependencies: locate-path: 3.0.0 @@ -32140,6 +32245,10 @@ snapshots: follow-redirects@1.15.6: {} + follow-redirects@1.15.9(debug@4.4.0): + optionalDependencies: + debug: 4.4.0 + for-each@0.3.3: dependencies: is-callable: 1.2.7 @@ -33252,6 +33361,13 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 + jest-fetch-mock@3.0.3(encoding@0.1.13): + dependencies: + cross-fetch: 3.1.8(encoding@0.1.13) + promise-polyfill: 8.3.0 + transitivePeerDependencies: + - encoding + jest-get-type@29.6.3: {} jest-haste-map@29.7.0: @@ -34052,6 +34168,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.4.15 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + make-dir@4.0.0: dependencies: semver: 7.6.3 @@ -34339,6 +34459,44 @@ snapshots: '@types/whatwg-url': 11.0.4 whatwg-url: 13.0.0 + mongodb-memory-server-core@10.1.4(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3): + dependencies: + async-mutex: 0.5.0 + camelcase: 6.3.0 + debug: 4.4.0 + find-cache-dir: 3.3.2 + follow-redirects: 1.15.9(debug@4.4.0) + https-proxy-agent: 7.0.5 + mongodb: 6.12.0(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3) + new-find-package-json: 2.0.0 + semver: 7.6.3 + tar-stream: 3.1.7 + tslib: 2.8.1 + yauzl: 3.2.0 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - gcp-metadata + - kerberos + - mongodb-client-encryption + - snappy + - socks + - supports-color + + mongodb-memory-server@10.1.4(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3): + dependencies: + mongodb-memory-server-core: 10.1.4(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3) + tslib: 2.8.1 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - gcp-metadata + - kerberos + - mongodb-client-encryption + - snappy + - socks + - supports-color + mongodb@6.12.0(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3): dependencies: '@mongodb-js/saslprep': 1.1.9 @@ -34416,6 +34574,12 @@ snapshots: netmask@2.0.2: {} + new-find-package-json@2.0.0: + dependencies: + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + next-tick@1.1.0: {} nice-try@1.0.5: {} @@ -35201,6 +35365,8 @@ snapshots: promise-inflight@1.0.1: {} + promise-polyfill@8.3.0: {} + promise-retry@2.0.1: dependencies: err-code: 2.0.3 @@ -35714,6 +35880,15 @@ snapshots: parse-srcset: 1.0.2 postcss: 8.5.3 + sanitize-html@2.16.0: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 8.0.2 + is-plain-object: 5.0.0 + parse-srcset: 1.0.2 + postcss: 8.5.3 + sass-loader@13.3.3(sass@1.71.1)(webpack@5.97.1): dependencies: neo-async: 2.6.2 @@ -37616,6 +37791,11 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yauzl@3.2.0: + dependencies: + buffer-crc32: 0.2.13 + pend: 1.2.0 + yjs@13.6.23: dependencies: lib0: 0.2.99 diff --git a/dev/tool/package.json b/dev/tool/package.json index 20c9dc357f..5e54f2a454 100644 --- a/dev/tool/package.json +++ b/dev/tool/package.json @@ -55,7 +55,8 @@ "@types/request": "~2.48.8", "jest": "^29.7.0", "ts-jest": "^29.1.1", - "@types/jest": "^29.5.5" + "@types/jest": "^29.5.5", + "mongodb-memory-server": "^10.1.4" }, "dependencies": { "@elastic/elasticsearch": "^7.17.14", @@ -160,6 +161,7 @@ "@hcengineering/collaboration": "^0.6.0", "@hcengineering/datalake": "^0.6.0", "@hcengineering/s3": "^0.6.0", + "@hcengineering/kvs-client": "^0.6.0", "commander": "^8.1.0", "csv-parse": "~5.1.0", "email-addresses": "^5.0.0", diff --git a/dev/tool/src/__start.ts b/dev/tool/src/__start.ts index 031a099d3c..b1d6e027b4 100644 --- a/dev/tool/src/__start.ts +++ b/dev/tool/src/__start.ts @@ -103,4 +103,13 @@ export function getAccountDBUrl (): string { return url } +export function getKvsUrl (): string { + const url = process.env.KVS_URL + if (url === undefined) { + console.error('please provide KVS_URL') + process.exit(1) + } + return url +} + devTool(prepareTools) diff --git a/dev/tool/src/__tests__/gmail.test.ts b/dev/tool/src/__tests__/gmail.test.ts new file mode 100644 index 0000000000..79425ac035 --- /dev/null +++ b/dev/tool/src/__tests__/gmail.test.ts @@ -0,0 +1,311 @@ +import { getAccountClient } from '@hcengineering/server-client' +import { getClient as getKvsClient } from '@hcengineering/kvs-client' +import { generateToken } from '@hcengineering/server-token' +import { MongoMemoryServer } from 'mongodb-memory-server' +import { MongoClient, type Db } from 'mongodb' +import { performGmailAccountMigrations } from '../gmail' + +// Mock dependencies +jest.mock('@hcengineering/server-client') +jest.mock('@hcengineering/server-token') +jest.mock('@hcengineering/kvs-client') + +describe('Gmail Migrations', () => { + // Setup MongoDB in-memory server + let mongoServer: MongoMemoryServer + let mongoClient: MongoClient + let db: Db + + // Mock implementations + const mockAccountClient = { + listWorkspaces: jest.fn(), + findFullSocialIdBySocialKey: jest.fn(), + getIntegration: jest.fn(), + createIntegration: jest.fn(), + getIntegrationSecret: jest.fn(), + addIntegrationSecret: jest.fn(), + updateIntegrationSecret: jest.fn() + } + + const mockKvsClient = { + getValue: jest.fn(), + setValue: jest.fn(), + deleteKey: jest.fn(), + listKeys: jest.fn() + } + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create() + const uri = mongoServer.getUri() + mongoClient = await MongoClient.connect(uri) + db = mongoClient.db('test-db') + + // Setup mocks + ;(getAccountClient as jest.Mock).mockReturnValue(mockAccountClient) + ;(getKvsClient as jest.Mock).mockReturnValue(mockKvsClient) + ;(generateToken as jest.Mock).mockReturnValue('mock-token') + }) + + afterAll(async () => { + await mongoClient.close() + await mongoServer.stop() + }) + + beforeEach(async () => { + // Reset mocks + jest.clearAllMocks() + + // Setup collections + await db.collection('tokens').deleteMany({}) + await db.collection('histories').deleteMany({}) + + // Reset mock implementations + mockAccountClient.listWorkspaces.mockReset() + mockAccountClient.findFullSocialIdBySocialKey.mockReset() + mockAccountClient.getIntegration.mockReset() + mockAccountClient.createIntegration.mockReset() + mockAccountClient.getIntegrationSecret.mockReset() + mockAccountClient.addIntegrationSecret.mockReset() + mockAccountClient.updateIntegrationSecret.mockReset() + + mockKvsClient.getValue.mockReset() + mockKvsClient.setValue.mockReset() + }, 10000) + + it('should migrate tokens to new integration format', async () => { + // Setup test data + const workspace1 = { uuid: 'ws1', name: 'Workspace 1' } + const workspace2 = { uuid: 'ws2', dataId: 'oldWs2', name: 'Workspace 2' } + + // Mock workspace list + mockAccountClient.listWorkspaces.mockResolvedValue([workspace1, workspace2]) + + // Setup tokens in DB + await db.collection('tokens').insertMany([ + { + userId: 'user1@example.com', + workspace: 'ws1', + token: 'token1', + refresh_token: 'refresh1', + access_token: 'access1' + }, + { + userId: 'user2@example.com', + workspace: 'oldWs2', + token: 'token2', + refresh_token: 'refresh2', + access_token: 'access2' + } + ]) + + // Mock social ID lookup + mockAccountClient.findFullSocialIdBySocialKey.mockImplementation((key) => { + if (key === 'email:user1@example.com') { + return Promise.resolve({ _id: 'social1', personUuid: 'person1' }) + } else if (key === 'email:user2@example.com') { + return Promise.resolve({ _id: 'social2', personUuid: 'person2' }) + } + return Promise.resolve(undefined) + }) + + // Mock integration checks + mockAccountClient.getIntegration.mockResolvedValue(null) + mockAccountClient.getIntegrationSecret.mockResolvedValue(null) + + // Run migration + await performGmailAccountMigrations(db, 'test-region', 'http://kvs-url') + + // Verify tokens were migrated + expect(mockAccountClient.createIntegration).toHaveBeenCalledTimes(2) + expect(mockAccountClient.addIntegrationSecret).toHaveBeenCalledTimes(2) + + // Check that tokens were migrated with correct data + const calls = mockAccountClient.addIntegrationSecret.mock.calls + expect(calls).toContainEqual([ + expect.objectContaining({ + socialId: 'social1', + workspaceUuid: 'ws1', + secret: expect.stringContaining('user1@example.com') + }) + ]) + expect(calls).toContainEqual([ + expect.objectContaining({ + socialId: 'social2', + workspaceUuid: 'ws2', + secret: expect.stringContaining('user2@example.com') + }) + ]) + }) + + it('should migrate with oids and github ids', async () => { + // Setup test data + const workspace1 = { uuid: 'ws1', name: 'Workspace 1' } + const workspace2 = { uuid: 'ws2', dataId: 'oldWs2', name: 'Workspace 2' } + + // Mock workspace list + mockAccountClient.listWorkspaces.mockResolvedValue([workspace1, workspace2]) + + // Setup tokens in DB + await db.collection('tokens').insertMany([ + { + userId: 'github:user1', + workspace: 'ws1', + token: 'token1', + refresh_token: 'refresh1', + access_token: 'access1' + }, + { + userId: 'openid:user2', + workspace: 'oldWs2', + token: 'token2', + refresh_token: 'refresh2', + access_token: 'access2' + } + ]) + + // Mock social ID lookup + mockAccountClient.findFullSocialIdBySocialKey.mockImplementation((key) => { + if (key === 'github:user1') { + return Promise.resolve({ _id: 'social1', personUuid: 'person1' }) + } else if (key === 'oidc:user2') { + return Promise.resolve({ _id: 'social2', personUuid: 'person2' }) + } + return Promise.resolve(undefined) + }) + + // Mock integration checks + mockAccountClient.getIntegration.mockResolvedValue(null) + mockAccountClient.getIntegrationSecret.mockResolvedValue(null) + + // Run migration + await performGmailAccountMigrations(db, 'test-region', 'http://kvs-url') + + // Verify tokens were migrated + expect(mockAccountClient.createIntegration).toHaveBeenCalledTimes(2) + expect(mockAccountClient.addIntegrationSecret).toHaveBeenCalledTimes(2) + + // Check that tokens were migrated with correct data + const calls = mockAccountClient.addIntegrationSecret.mock.calls + expect(calls).toContainEqual([ + expect.objectContaining({ + socialId: 'social1', + workspaceUuid: 'ws1', + secret: expect.stringContaining('github:user1') + }) + ]) + expect(calls).toContainEqual([ + expect.objectContaining({ + socialId: 'social2', + workspaceUuid: 'ws2', + secret: expect.stringContaining('openid:user2') + }) + ]) + }) + + it('should migrate history records to KVS', async () => { + // Setup test data + const workspace1 = { uuid: 'ws1', name: 'Workspace 1' } + + // Mock workspace list + mockAccountClient.listWorkspaces.mockResolvedValue([workspace1]) + + // Setup histories in DB + await db.collection('histories').insertMany([ + { + userId: 'user1@example.com', + workspace: 'ws1', + token: 'token1', + historyId: 'history1' + } + ]) + + // Mock social ID lookup + mockAccountClient.findFullSocialIdBySocialKey.mockImplementation((key) => { + if (key === 'email:user1@example.com') { + return Promise.resolve({ _id: 'social1', personUuid: 'person1' }) + } + return Promise.resolve(undefined) + }) + + // Mock KVS client responses + mockKvsClient.getValue.mockResolvedValue(null) + + // Run migration + await performGmailAccountMigrations(db, 'test-region', 'http://kvs-url') + + // Verify KVS calls + expect(mockKvsClient.setValue).toHaveBeenCalledWith( + 'history:ws1:social1', + expect.objectContaining({ + historyId: 'history1', + email: 'user1@example.com', + userId: 'person1', + workspace: 'ws1' + }) + ) + }) + + it('should handle errors gracefully', async () => { + // Mock console.error to capture errors + const originalConsoleError = console.error + console.error = jest.fn() + + // Setup test data that will cause errors + mockAccountClient.listWorkspaces.mockResolvedValue([]) + mockAccountClient.findFullSocialIdBySocialKey.mockRejectedValue(new Error('Network error')) + + // Insert some data + await db.collection('tokens').insertOne({ + userId: 'error@example.com', + workspace: 'non-existent', + token: 'token-error' + }) + + // Run migration + await performGmailAccountMigrations(db, 'test-region', 'http://kvs-url') + + // Should not throw but log errors + expect(console.error).toHaveBeenCalled() + + // Restore console.error + console.error = originalConsoleError + }) + + it('should update existing integration secrets', async () => { + // Setup test data + const workspace = { uuid: 'ws1', name: 'Workspace 1' } + + // Mock workspace list + mockAccountClient.listWorkspaces.mockResolvedValue([workspace]) + + // Setup token in DB + await db.collection('tokens').insertOne({ + userId: 'user1@example.com', + workspace: 'ws1', + token: 'token1', + refresh_token: 'refresh1' + }) + + // Mock social ID lookup + mockAccountClient.findFullSocialIdBySocialKey.mockResolvedValue({ _id: 'social1', personUuid: 'person1' }) + + // Mock existing integration and token + mockAccountClient.getIntegration.mockResolvedValue({ _id: 'integration1' }) + mockAccountClient.getIntegrationSecret.mockResolvedValue({ + scope: 'old-scope', + token_type: 'Bearer' + }) + + // Run migration + await performGmailAccountMigrations(db, 'test-region', 'http://kvs-url') + + // Verify update was called instead of add + expect(mockAccountClient.createIntegration).not.toHaveBeenCalled() + expect(mockAccountClient.addIntegrationSecret).not.toHaveBeenCalled() + expect(mockAccountClient.updateIntegrationSecret).toHaveBeenCalledWith( + expect.objectContaining({ + secret: expect.stringContaining('refresh1') + }) + ) + }) +}) diff --git a/dev/tool/src/gmail.ts b/dev/tool/src/gmail.ts new file mode 100644 index 0000000000..b544017920 --- /dev/null +++ b/dev/tool/src/gmail.ts @@ -0,0 +1,225 @@ +import { + type AccountUuid, + type PersonId, + type SocialId, + type WorkspaceInfoWithStatus, + type WorkspaceUuid, + buildSocialIdString, + systemAccountUuid +} from '@hcengineering/core' +import { getAccountClient } from '@hcengineering/server-client' +import { getClient as getKvsClient } from '@hcengineering/kvs-client' +import { generateToken } from '@hcengineering/server-token' +import { getSocialKeyByOldEmail } from '@hcengineering/model-core' +import type { Db } from 'mongodb' + +// Old token and history types +interface Credentials { + refresh_token?: string | null + expiry_date?: number | null + access_token?: string | null + token_type?: string | null + id_token?: string | null + scope?: string +} + +interface User { + userId: string + workspace: WorkspaceUuid + token: string +} + +type History = User & { + historyId: string +} + +// Updated token and history types +interface UserV2 { + userId: AccountUuid + email?: string + socialId: SocialId + workspace: WorkspaceUuid + token: string +} + +export type TokenV2 = UserV2 & Credentials + +export type HistoryV2 = UserV2 & { + historyId: string +} + +type Token = User & Credentials + +interface WorkspaceInfoProvider { + getWorkspaceInfo: (workspaceUuid: WorkspaceUuid) => Promise +} + +const GMAIL_INTEGRATION = 'gmail' +const TOKEN_TYPE = 'token' + +export async function performGmailAccountMigrations (db: Db, region: string | null, kvsUrl: string): Promise { + console.log('Start Gmail migrations') + const token = generateToken(systemAccountUuid, '' as WorkspaceUuid, { service: 'admin', admin: 'true' }) + const accountClient = getAccountClient(token) + + const allWorkpaces = await accountClient.listWorkspaces(region) + const byId = new Map(allWorkpaces.map((it) => [it.uuid, it])) + const oldNewIds = new Map(allWorkpaces.map((it) => [it.dataId ?? it.uuid, it])) + const workspaceProvider: WorkspaceInfoProvider = { + getWorkspaceInfo: async (workspaceUuid: WorkspaceUuid) => { + const ws = oldNewIds.get(workspaceUuid as any) ?? byId.get(workspaceUuid as any) + if (ws == null) { + console.error('No workspace found for token', workspaceUuid) + return undefined + } + return ws + } + } + + await migrateGmailIntegrations(db, token, workspaceProvider) + + await migrateGmailHistory(db, token, kvsUrl, workspaceProvider) + console.log('Finished Gmail migrations') +} + +async function migrateGmailIntegrations ( + db: Db, + token: string, + workspaceProvider: WorkspaceInfoProvider +): Promise { + try { + console.log('Start Gmail account migrations') + const gmailToken = generateToken(systemAccountUuid, '' as WorkspaceUuid, { service: 'gmail' }) + const accountClient = getAccountClient(token) + + const gmailAccountClient = getAccountClient(gmailToken) + + const tokens = db.collection('tokens') + + const allTokens = await tokens.find({}).toArray() + + for (const token of allTokens) { + try { + const ws = await workspaceProvider.getWorkspaceInfo(token.workspace as any) + if (ws == null) { + continue + } + token.workspace = ws.uuid + + const socialKey = buildSocialIdString(getSocialKeyByOldEmail(token.userId)) + const socialId = + socialKey !== undefined ? await accountClient.findFullSocialIdBySocialKey(socialKey) : undefined + if (socialId == null) { + console.error('No socialId found for token', token) + continue + } + // Check/create integration in account + const existing = await gmailAccountClient.getIntegration({ + kind: GMAIL_INTEGRATION, + workspaceUuid: ws?.uuid, + socialId: socialId._id + }) + + if (existing == null) { + await gmailAccountClient.createIntegration({ + kind: GMAIL_INTEGRATION, + workspaceUuid: ws?.uuid, + socialId: socialId._id + }) + } + + const existingToken = await gmailAccountClient.getIntegrationSecret({ + key: TOKEN_TYPE, + kind: GMAIL_INTEGRATION, + socialId: socialId._id, + workspaceUuid: ws?.uuid + }) + const newToken: TokenV2 = { + ...token, + workspace: ws?.uuid, + email: token.userId, + userId: socialId.personUuid as AccountUuid, + socialId + } + if (existingToken == null) { + await gmailAccountClient.addIntegrationSecret({ + key: TOKEN_TYPE, + kind: GMAIL_INTEGRATION, + socialId: socialId._id, + secret: JSON.stringify(newToken), + workspaceUuid: token.workspace + }) + } else { + const updatedToken = { + ...existingToken, + ...newToken + } + await gmailAccountClient.updateIntegrationSecret({ + key: TOKEN_TYPE, + kind: GMAIL_INTEGRATION, + socialId: socialId._id, + secret: JSON.stringify(updatedToken), + workspaceUuid: token.workspace + }) + } + } catch (e) { + console.error('Error migrating token', token, e) + } + } + console.log('Gmail integrations migrations done, integration count:', allTokens.length) + } catch (e) { + console.error('Error migrating tokens', e) + } +} + +async function migrateGmailHistory ( + db: Db, + token: string, + kvsUrl: string, + workspaceProvider: WorkspaceInfoProvider +): Promise { + try { + console.log('Start Gmail history migrations') + const accountClient = getAccountClient(token) + const history = db.collection('histories') + const allHistories = await history.find({}).toArray() + + const kvsClient = getKvsClient(kvsUrl, token) + + for (const history of allHistories) { + try { + const socialKey = buildSocialIdString(getSocialKeyByOldEmail(history.userId)) + const socialId = + socialKey !== undefined ? await accountClient.findFullSocialIdBySocialKey(socialKey) : undefined + if (socialId == null) { + console.error('No socialId found for history', history) + continue + } + // Update/create history in KVS + const ws = await workspaceProvider.getWorkspaceInfo(history.workspace as any) + if (ws == null) { + continue + } + const historyKey = getHistoryKey(ws.uuid, socialId._id) + const existingHistory = await kvsClient.getValue(historyKey) + const updatedHistory: HistoryV2 = { + ...(existingHistory ?? history), + email: history.userId, + workspace: ws.uuid, + userId: socialId.personUuid as AccountUuid, + socialId + } + await kvsClient.setValue(historyKey, updatedHistory) + } catch (e) { + console.error('Error migrating history', history, e) + } + } + console.log('Finished migrating gmail history, count:', allHistories.length) + } catch (e) { + console.error('Error migrating gmail history', e) + } +} + +function getHistoryKey (workspace: WorkspaceUuid, userId: PersonId): string { + return `history:${workspace}:${userId}` +} diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index 20a1577a80..93cffba3d6 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -89,13 +89,14 @@ import { type QueueWorkspaceMessage, type StorageAdapter } from '@hcengineering/server-core' -import { getAccountDBUrl, getMongoDBUrl } from './__start' +import { getAccountDBUrl, getKvsUrl, getMongoDBUrl } from './__start' // import { fillGithubUsers, fixAccountEmails, renameAccount } from './account' import { changeConfiguration } from './configuration' import { performGithubAccountMigrations } from './github' import { migrateCreatedModifiedBy, ensureGlobalPersonsForLocalAccounts, moveAccountDbFromMongoToPG } from './db' import { getToolToken, getWorkspace, getWorkspaceTransactorEndpoint } from './utils' +import { performGmailAccountMigrations } from './gmail' const colorConstants = { colorRed: '\u001b[31m', @@ -2429,6 +2430,21 @@ export function devTool ( await queue.createTopics(parseInt(cmd.tx ?? '1')) }) + program + .command('migrate-gmail-account') + .option('--db ', 'DB name', 'gmail-service') + .option('--region ', 'DB region') + .action(async (cmd: { db: string, region?: string }) => { + const mongodbUri = getMongoDBUrl() + const client = getMongoClient(mongodbUri) + const _client = await client.getClient() + + const kvsUrl = getKvsUrl() + await performGmailAccountMigrations(_client.db(cmd.db), cmd.region ?? null, kvsUrl) + await _client.close() + client.close() + }) + extendProgram?.(program) program.parse(process.argv) diff --git a/packages/kvs-client/.eslintrc.js b/packages/kvs-client/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/packages/kvs-client/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/kvs-client/.npmignore b/packages/kvs-client/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/kvs-client/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/kvs-client/config/rig.json b/packages/kvs-client/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/packages/kvs-client/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/packages/kvs-client/jest.config.js b/packages/kvs-client/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/kvs-client/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/kvs-client/jest.setup.js b/packages/kvs-client/jest.setup.js new file mode 100644 index 0000000000..78d57e3d23 --- /dev/null +++ b/packages/kvs-client/jest.setup.js @@ -0,0 +1,2 @@ +// Set up fetch mock +require('jest-fetch-mock').enableMocks() diff --git a/packages/kvs-client/package.json b/packages/kvs-client/package.json new file mode 100644 index 0000000000..dea934495a --- /dev/null +++ b/packages/kvs-client/package.json @@ -0,0 +1,58 @@ +{ + "name": "@hcengineering/kvs-client", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc.", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "cross-env": "~7.0.3", + "@hcengineering/platform-rig": "^0.6.0", + "@types/node": "~20.11.16", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "esbuild": "^0.24.2", + "@typescript-eslint/parser": "^6.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.3.3", + "jest": "^29.7.0", + "jest-fetch-mock": "^3.0.3", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5" + }, + "dependencies": { + "@hcengineering/core": "^0.6.32", + "@hcengineering/platform": "^0.6.11" + }, + "repository": "https://github.com/hcengineering/platform", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/packages/kvs-client/src/__tests__/client.test.ts b/packages/kvs-client/src/__tests__/client.test.ts new file mode 100644 index 0000000000..f7820d6493 --- /dev/null +++ b/packages/kvs-client/src/__tests__/client.test.ts @@ -0,0 +1,201 @@ +import { getClient } from '../client' +import { KeyValueClient } from '../types' +import fetchMock from 'jest-fetch-mock' + +// Enable fetch mocks +fetchMock.enableMocks() + +describe('KeyValueClient', () => { + let client: KeyValueClient + const namespace = 'test-ns' + + beforeEach(() => { + // Reset fetch mocks for each test + fetchMock.resetMocks() + + // Default successful response for all fetch calls + fetchMock.mockResponse(JSON.stringify({ key: 'value' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + + client = getClient(namespace, 'https://api.example.com', 'test-token') + }) + + afterEach(() => { + fetchMock.resetMocks() + }) + + it('should throw error when baseUrl is empty', () => { + expect(() => getClient('test-ns', '')).toThrow('Key-value API URL not specified') + }) + + it('should throw error when namespace is empty', () => { + expect(() => getClient('', 'https://api.example.com')).toThrow('Namespace not specified') + }) + + describe('setValue', () => { + it('should send POST request with correct headers and body', async () => { + // Mock 204 response for setValue + fetchMock.mockResponseOnce('', { status: 204 }) + + const key = 'test-key' + const value = { data: 'test-value' } + + await client.setValue(key, value) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/test-ns/test-key', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + Authorization: 'Bearer test-token', + Connection: 'keep-alive' + }), + body: JSON.stringify(value) + }) + ) + }) + + it('should throw error when response status is not 204', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ error: 'Server error' }), { status: 500 }) + + await expect(client.setValue('key', 'value')).rejects.toThrow('Failed to store value') + }) + }) + + describe('getValue', () => { + it('should send GET request with correct headers', async () => { + const testData = { message: 'test data' } + fetchMock.mockResponseOnce(JSON.stringify(testData), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + + const result = await client.getValue('key') + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/test-ns/key', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + Connection: 'keep-alive' + }) + }) + ) + expect(result).toEqual(testData) + }) + + it('should return null when key does not exist (404)', async () => { + fetchMock.mockResponseOnce('', { status: 404 }) + + const result = await client.getValue('key') + + expect(result).toBeNull() + }) + + it('should throw error for non-404 error status', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ error: 'Server error' }), { status: 500 }) + + await expect(client.getValue('key')).rejects.toThrow('Request failed') + }) + }) + + describe('deleteKey', () => { + it('should send DELETE request with correct headers', async () => { + fetchMock.mockResponseOnce('', { status: 204 }) + + await client.deleteKey('key') + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/test-ns/key', + expect.objectContaining({ + method: 'DELETE', + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + Connection: 'keep-alive' + }) + }) + ) + }) + + it('should not throw error when key does not exist (404)', async () => { + fetchMock.mockResponseOnce('', { status: 404 }) + + await expect(client.deleteKey('key')).resolves.toBeUndefined() + }) + + it('should throw error for status other than 204 or 404', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ error: 'Server error' }), { status: 500 }) + + await expect(client.deleteKey('key')).rejects.toThrow('Failed to delete key') + }) + }) + + describe('listKeys', () => { + it('should send GET request to namespace endpoint', async () => { + const testData = { key1: 'value1', key2: 'value2' } + fetchMock.mockResponseOnce(JSON.stringify(testData), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + + const result = await client.listKeys() + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/test-ns', + expect.objectContaining({ method: 'GET' }) + ) + expect(result).toEqual(testData) + }) + + it('should include prefix query parameter when provided', async () => { + fetchMock.mockResponseOnce(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + + await client.listKeys('prefix') + + expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/test-ns?prefix=prefix', expect.anything()) + }) + + it('should encode namespace and prefix properly', async () => { + // Create client with a namespace that needs encoding + const specialClient = getClient('name/space', 'https://api.example.com', 'test-token') + + fetchMock.mockResponseOnce(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + + await specialClient.listKeys('pre/fix') + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/name%2Fspace?prefix=pre%2Ffix', + expect.anything() + ) + }) + }) + + describe('retry logic', () => { + it('should retry on network errors', async () => { + // First call throws network error, second succeeds + fetchMock.mockRejectOnce(new Error('Network error')).mockResponseOnce(JSON.stringify({ key: 'value' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + + const result = await client.getValue('key') + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(result).toEqual({ key: 'value' }) + }) + }) +}) diff --git a/packages/kvs-client/src/client.ts b/packages/kvs-client/src/client.ts new file mode 100644 index 0000000000..07f8a352f1 --- /dev/null +++ b/packages/kvs-client/src/client.ts @@ -0,0 +1,200 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { concatLink } from '@hcengineering/core' +import { PlatformError } from '@hcengineering/platform' +import { KeyValueClient } from './types' + +/** + * Get a KeyValueClient instance + * @param namespace - Namespace for the key-value operations + * @param baseUrl - URL of the key-value API server + * @param token - Optional authorization token + * @param retryTimeoutMs - Optional timeout for retrying failed requests + * @returns KeyValueClient instance + * @public + */ +export function getClient (namespace: string, baseUrl: string, token?: string, retryTimeoutMs?: number): KeyValueClient { + if (baseUrl === undefined) { + throw new Error('Key-value API URL not specified') + } + + return new KeyValueClientImpl(namespace, baseUrl, token, retryTimeoutMs) +} + +class KeyValueClientImpl implements KeyValueClient { + private readonly requestInit: RequestInit + + constructor ( + private readonly namespace: string, + private readonly baseUrl: string, + private readonly token?: string, + private readonly retryTimeoutMs: number = 5000 + ) { + if (baseUrl === '') { + throw new Error('Key-value API URL not specified') + } + + if (namespace === '' || namespace === undefined) { + throw new Error('Namespace not specified') + } + + const isBrowser = typeof window !== 'undefined' + + this.requestInit = { + keepalive: true, + headers: { + ...(this.token === undefined + ? {} + : { + Authorization: 'Bearer ' + this.token + }) + }, + ...(isBrowser ? { credentials: 'include' } : {}) + } + } + + async setValue(key: string, value: T): Promise { + const url = this.buildUrl(key) + await this.sendRequest(url, { + method: 'POST', + body: JSON.stringify(value), + contentType: 'application/json', + errorMessage: 'Failed to store value' + }) + } + + async getValue(key: string): Promise { + const url = this.buildUrl(key) + return await this.sendRequest(url, { + method: 'GET', + returnNullOn404: true + }) + } + + async deleteKey (key: string): Promise { + const url = this.buildUrl(key) + await this.sendRequest(url, { + method: 'DELETE', + acceptNotFound: true, + errorMessage: 'Failed to delete key' + }) + } + + async listKeys(prefix?: string): Promise | null> { + let url = this.buildUrl() + if (prefix !== undefined) { + url += `?prefix=${encodeURIComponent(prefix)}` + } + return await this.sendRequest>(url, { + method: 'GET', + errorMessage: 'Failed to list keys' + }) + } + + private buildUrl (key?: string): string { + const baseApiUrl = concatLink(this.baseUrl, `/api/${encodeURIComponent(this.namespace)}`) + return key !== undefined ? concatLink(baseApiUrl, encodeURIComponent(key)) : baseApiUrl + } + + private async sendRequest( + url: string, + options: { + method: string + body?: string + contentType?: string + returnNullOn404?: boolean + acceptNotFound?: boolean + errorMessage?: string + } + ): Promise { + const headers = { + ...this.requestInit.headers, + ...(options.contentType !== undefined && options.contentType !== '' + ? { 'Content-Type': options.contentType } + : {}) + } + + const response = await this.fetchWithRetry(url, { + ...this.requestInit, + method: options.method, + headers, + body: options.body + }) + + if (options.returnNullOn404 === true && response.status === 404) { + return null + } + + if (options.acceptNotFound === true && response.status === 404) { + return null + } + + if (!response.ok) { + try { + const errorBody = await response.json() + if (errorBody?.error != null) { + throw new PlatformError(errorBody?.error) + } + } catch (e) { + // Ignore JSON parsing errors + } + throw new Error(options.errorMessage ?? 'Request failed') + } + + // Parse JSON response when needed + const contentType = response.headers.get('content-type') + if ( + response.status !== 204 && + (options.method === 'GET' || (contentType != null && contentType.includes('application/json'))) + ) { + return await response.json() + } + + return null + } + + private async fetchWithRetry (url: string, init: RequestInit): Promise { + const timeout = Date.now() + this.retryTimeoutMs + const connectionErrorCodes = ['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND'] + let intervalMs = 25 + + while (true) { + try { + const headers = { + ...init.headers, + Connection: 'keep-alive' + } + + return await fetch(url, { ...init, headers }) + } catch (err: any) { + // Check if this is a network error we should retry + const errMessage: string = err?.message ?? '' + const isNetworkError = + connectionErrorCodes.includes(err?.cause?.code) || + errMessage.includes('Network error') || + errMessage.includes('network') + + if (!isNetworkError || timeout < Date.now()) { + throw err + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + if (intervalMs < 1000) { + intervalMs += 100 + } + } + } + } +} diff --git a/packages/kvs-client/src/index.ts b/packages/kvs-client/src/index.ts new file mode 100644 index 0000000000..0f078ded71 --- /dev/null +++ b/packages/kvs-client/src/index.ts @@ -0,0 +1,17 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export * from './client' +export * from './types' diff --git a/packages/kvs-client/src/types.ts b/packages/kvs-client/src/types.ts new file mode 100644 index 0000000000..5f2d1d413a --- /dev/null +++ b/packages/kvs-client/src/types.ts @@ -0,0 +1,49 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +/** + * Client for interacting with the key-value store API + * @public + */ +export interface KeyValueClient { + /** + * Store a value for a key in a namespace + * @param key - The key to store the value under + * @param value - The value to store + * @returns Promise that resolves when the value is stored + */ + setValue: (key: string, value: T) => Promise + + /** + * Retrieve a value for a key in a namespace + * @param key - The key to retrieve the value for + * @returns Promise that resolves to the value, or null if not found + */ + getValue: (key: string) => Promise + + /** + * Delete a key-value pair in a namespace + * @param key - The key to delete + * @returns Promise that resolves when the key is deleted + */ + deleteKey: (key: string) => Promise + + /** + * List all key-value pairs in a namespace, optionally filtered by prefix + * @param prefix - Optional prefix to filter keys by + * @returns Promise that resolves to an object with keys and their values + */ + listKeys: (prefix?: string) => Promise | null> +} diff --git a/packages/kvs-client/tsconfig.json b/packages/kvs-client/tsconfig.json new file mode 100644 index 0000000000..59e4fd4297 --- /dev/null +++ b/packages/kvs-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + } +} \ No newline at end of file diff --git a/plugins/gmail-assets/lang/cs.json b/plugins/gmail-assets/lang/cs.json index 15bca6da7d..1e626bb02a 100644 --- a/plugins/gmail-assets/lang/cs.json +++ b/plugins/gmail-assets/lang/cs.json @@ -38,6 +38,7 @@ "ConfigLabel": "E-mail", "ConfigDescription": "Rozšíření pro integraci e-mailů Gmail", "GooglePrivacy": "Použití a přenos informací získaných z Google API do jakékoli jiné aplikace bude v souladu s zásadami Google API Services User Data Policy, včetně požadavků na omezené použití.", - "EmailNotificationsDescription": "Dostávejte osobní oznámení na e-mail." + "EmailNotificationsDescription": "Dostávejte osobní oznámení na e-mail.", + "FailedToConnect": "Nepodařilo se připojit k Gmail" } } \ No newline at end of file diff --git a/plugins/gmail-assets/lang/de.json b/plugins/gmail-assets/lang/de.json index 8f0c1a991b..7689d8f192 100644 --- a/plugins/gmail-assets/lang/de.json +++ b/plugins/gmail-assets/lang/de.json @@ -38,6 +38,7 @@ "ConfigLabel": "E-Mail", "ConfigDescription": "Erweiterung für Gmail-Integration", "GooglePrivacy": "Die Nutzung und Übertragung von Informationen, die Huly von Google-APIs erhält, an andere Apps erfolgt gemäß der Google API Services User Data Policy, einschließlich der Anforderungen zur eingeschränkten Nutzung.", - "EmailNotificationsDescription": "Erhalten Sie persönliche Benachrichtigungen per E-Mail." + "EmailNotificationsDescription": "Erhalten Sie persönliche Benachrichtigungen per E-Mail.", + "FailedToConnect": "Fehler beim Verbinden mit Gmail" } } \ No newline at end of file diff --git a/plugins/gmail-assets/lang/en.json b/plugins/gmail-assets/lang/en.json index f18b7665c4..a7480da6eb 100644 --- a/plugins/gmail-assets/lang/en.json +++ b/plugins/gmail-assets/lang/en.json @@ -38,6 +38,7 @@ "ConfigLabel": "Email", "ConfigDescription": "Extension for Gmail email integration", "GooglePrivacy": "Huly’s use and transfer of information received from Google APIs to any other app will adhere to Google API Services User Data Policy, including the Limited Use requirements.", - "EmailNotificationsDescription": "Receive personal notifications on email." + "EmailNotificationsDescription": "Receive personal notifications on email.", + "FailedToConnect": "Failed to connect to Gmail" } } \ No newline at end of file diff --git a/plugins/gmail-assets/lang/es.json b/plugins/gmail-assets/lang/es.json index 3a38407cb9..e5c3ecd199 100644 --- a/plugins/gmail-assets/lang/es.json +++ b/plugins/gmail-assets/lang/es.json @@ -38,6 +38,7 @@ "ConfigLabel": "Correo Electrónico", "ConfigDescription": "Extensión para la integración de correo electrónico de Gmail", "GooglePrivacy": "Huly’s use and transfer of information received from Google APIs to any other app will adhere to Google API Services User Data Policy, including the Limited Use requirements.", - "EmailNotificationsDescription": "Reciba notificaciones personales por correo electrónico." + "EmailNotificationsDescription": "Reciba notificaciones personales por correo electrónico.", + "FailedToConnect": "Error al conectar con Gmail" } } \ No newline at end of file diff --git a/plugins/gmail-assets/lang/fr.json b/plugins/gmail-assets/lang/fr.json index 173e4c6065..c05a212fd0 100644 --- a/plugins/gmail-assets/lang/fr.json +++ b/plugins/gmail-assets/lang/fr.json @@ -38,6 +38,7 @@ "ConfigLabel": "Email", "ConfigDescription": "Extension pour l'intégration des emails Gmail", "GooglePrivacy": "L'utilisation et le transfert des informations reçues des API Google par Huly à toute autre application respecteront les règles d'utilisation des données utilisateur des services API Google, y compris les exigences d'utilisation limitée.", - "EmailNotificationsDescription": "Recevez des notifications personnelles par e-mail." + "EmailNotificationsDescription": "Recevez des notifications personnelles par e-mail.", + "FailedToConnect": "Impossible de se connecter au compte Gmail" } } \ No newline at end of file diff --git a/plugins/gmail-assets/lang/it.json b/plugins/gmail-assets/lang/it.json index 68fc2a19c2..72c775d58f 100644 --- a/plugins/gmail-assets/lang/it.json +++ b/plugins/gmail-assets/lang/it.json @@ -38,6 +38,7 @@ "ConfigLabel": "Email", "ConfigDescription": "Estensione per l'integrazione delle email di Gmail", "GooglePrivacy": "L'uso e il trasferimento delle informazioni ricevute dalle API di Google a qualsiasi altra app di Huly adempirà alla Politica sui dati degli utenti dei servizi API di Google, inclusi i requisiti di utilizzo limitato.", - "EmailNotificationsDescription": "Ricevi notifiche personali sulle email." + "EmailNotificationsDescription": "Ricevi notifiche personali sulle email.", + "FailedToConnect": "Impossibile connettere l'account Gmail" } } diff --git a/plugins/gmail-assets/lang/pt.json b/plugins/gmail-assets/lang/pt.json index 35a2ad469c..1eea278f1c 100644 --- a/plugins/gmail-assets/lang/pt.json +++ b/plugins/gmail-assets/lang/pt.json @@ -38,6 +38,7 @@ "ConfigLabel": "Email", "ConfigDescription": "Extensão para a integração de email do Gmail", "GooglePrivacy": "Huly’s use and transfer of information received from Google APIs to any other app will adhere to Google API Services User Data Policy, including the Limited Use requirements.", - "EmailNotificationsDescription": "Receba notificações pessoais por e-mail." + "EmailNotificationsDescription": "Receba notificações pessoais por e-mail.", + "FailedToConnect": "Não foi possível conectar a conta do Gmail" } } \ No newline at end of file diff --git a/plugins/gmail-assets/lang/ru.json b/plugins/gmail-assets/lang/ru.json index d395af3142..f1ad4201ba 100644 --- a/plugins/gmail-assets/lang/ru.json +++ b/plugins/gmail-assets/lang/ru.json @@ -38,6 +38,7 @@ "ConfigLabel": "Электронная почта", "ConfigDescription": "Расширение по работе с Gmail электронной почтой", "GooglePrivacy": "Использование и передача информации, полученной Huly от Google API, будет соответствовать Политике использования данных пользователей Google API, включая требования ограниченного использования.", - "EmailNotificationsDescription": "Получайте персональные уведомления на электронную почту." + "EmailNotificationsDescription": "Получайте персональные уведомления на электронную почту.", + "FailedToConnect": "Не удалось подключиться к Gmail" } } \ No newline at end of file diff --git a/plugins/gmail-assets/lang/zh.json b/plugins/gmail-assets/lang/zh.json index fdbb854092..32988675f8 100644 --- a/plugins/gmail-assets/lang/zh.json +++ b/plugins/gmail-assets/lang/zh.json @@ -38,6 +38,7 @@ "ConfigLabel": "电子邮件", "ConfigDescription": "Gmail 邮件集成扩展", "GooglePrivacy": "Huly 从 Google API 接收的信息的使用和传输将遵守 Google API 服务用户数据政策,包括有限使用要求。", - "EmailNotificationsDescription": "透過電子郵件接收個人通知。" + "EmailNotificationsDescription": "透過電子郵件接收個人通知。", + "FailedToConnect": "無法連接 Gmail 帳戶。請檢查您的憑證並重試。" } } diff --git a/plugins/gmail-resources/src/components/Chats.svelte b/plugins/gmail-resources/src/components/Chats.svelte index e293687b0c..2579596183 100644 --- a/plugins/gmail-resources/src/components/Chats.svelte +++ b/plugins/gmail-resources/src/components/Chats.svelte @@ -16,14 +16,12 @@ {#if channel && object} @@ -154,14 +163,7 @@ {:else if currentMessage} {:else} - 0} - on:select={selectHandler} - /> + 0} on:select={selectHandler} /> {/if} {/if} diff --git a/plugins/gmail-resources/src/plugin.ts b/plugins/gmail-resources/src/plugin.ts index eb0cdf3cb8..25c151b383 100644 --- a/plugins/gmail-resources/src/plugin.ts +++ b/plugins/gmail-resources/src/plugin.ts @@ -43,6 +43,7 @@ export default mergeIds(gmailId, gmail, { Email: '' as IntlString, HaveWrittenEmail: '' as IntlString, NewIncomingMessage: '' as IntlString, - GooglePrivacy: '' as IntlString + GooglePrivacy: '' as IntlString, + FailedToConnect: '' as IntlString } }) diff --git a/plugins/gmail-resources/src/utils.ts b/plugins/gmail-resources/src/utils.ts index a2cd0c1f45..0601ccff39 100644 --- a/plugins/gmail-resources/src/utils.ts +++ b/plugins/gmail-resources/src/utils.ts @@ -1,9 +1,10 @@ -// import { getName as getContactName } from '@hcengineering/contact' -import contact, { type Channel, type Contact, type Employee } from '@hcengineering/contact' -import { type Client, type Doc, type IdMap, type Ref } from '@hcengineering/core' +import { get } from 'svelte/store' +import { getName as getContactName } from '@hcengineering/contact' +import contact, { type Channel, type Contact } from '@hcengineering/contact' +import { personByPersonIdStore, employeeBySocialKeyStore } from '@hcengineering/contact-resources' +import { buildSocialIdString, type PersonId, SocialIdType, type Client, type Doc, type Ref } from '@hcengineering/core' import { type Message, type SharedMessage } from '@hcengineering/gmail' import { getClient } from '@hcengineering/presentation' -import { type Integration } from '@hcengineering/setting' import gmail from './plugin' export function getTime (time: number): string { @@ -61,78 +62,53 @@ export async function checkHasEmail (doc: Doc | Doc[] | undefined): Promise()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/ +const EMAIL_REGEX = + /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/ -export function convertMessages ( - object: Contact, - channel: Channel, - messages: Message[], - integrations: Integration[], - accounts: any, - employees: IdMap -): SharedMessage[] { - // TODO: FIXME - throw new Error('Not implemented') - // return messages.map((m) => { - // return { - // ...m, - // _id: m._id as string as Ref, - // sender: getName(object, channel, m, integrations, accounts, employees, true), - // receiver: getName(object, channel, m, integrations, accounts, employees, false) - // } - // }) +export function convertMessages (object: Contact, channel: Channel, messages: Message[]): SharedMessage[] { + return messages.map((m) => { + return { + ...m, + _id: m._id as string as Ref, + sender: getName(object, channel, m, true), + receiver: getName(object, channel, m, false) + } + }) } -export async function convertMessage ( - object: Contact, - channel: Channel, - message: Message, - integrations: Integration[], - accounts: any, - employees: IdMap -): Promise { - // TODO: FIXME - throw new Error('Not implemented') - // return { - // ...message, - // _id: message._id as string as Ref, - // sender: getName(object, channel, message, integrations, accounts, employees, true), - // receiver: getName(object, channel, message, integrations, accounts, employees, false) - // } +export async function convertMessage (object: Contact, channel: Channel, message: Message): Promise { + return { + ...message, + _id: message._id as string as Ref, + sender: getName(object, channel, message, true), + receiver: getName(object, channel, message, false) + } } -export function getName ( - object: Contact, - channel: Channel, - message: Message, - integrations: Integration[], - accounts: any, - employees: IdMap, - sender: boolean -): string { - // TODO: FIXME - throw new Error('Not implemented') - // const h = getClient().getHierarchy() - // if (message._class === gmail.class.NewMessage) { - // if (!sender) return `${getContactName(h, object)} (${channel.value})` - // const from = message.from ?? message.createdBy ?? message.modifiedBy - // const account = accounts.get(from) - // const emp = account != null ? employees.get(account?.person as Ref) : undefined - // const integration = integrations.find((p) => p.createdBy === from) - // const email = integration?.value ?? integrations[0]?.value - // return emp != null ? `${getContactName(h, emp)} (${email})` : email - // } - // if (message.incoming === sender) { - // return `${getContactName(h, object)} (${channel.value})` - // } else { - // const account = accounts.get(message.modifiedBy) - // const emp = account != null ? employees.get(account?.person as Ref) : undefined - // const value = message.incoming ? message.to : message.from - // const email = value.match(EMAIL_REGEX) - // const emailVal = email?.[0] ?? value - // return emp != null ? `${getContactName(h, emp)} (${emailVal})` : emailVal - // } +export function getName (object: Contact, channel: Channel, message: Message, sender: boolean): string { + const h = getClient().getHierarchy() + if (message._class === gmail.class.NewMessage) { + if (!sender) return `${getContactName(h, object)} (${channel.value})` + return getPersonName(message.from ?? message.createdBy ?? message.modifiedBy) + } + if (message.incoming === sender) { + return `${getContactName(h, object)} (${channel.value})` + } else { + return getPersonName(message.modifiedBy) + } +} + +export function getPersonName (emailOrId: string): string { + const personName = get(personByPersonIdStore).get(emailOrId as PersonId)?.name + if (personName != null) return personName + const emailSearch = emailOrId.match(EMAIL_REGEX) + const email = emailSearch?.[0] + if (email != null && email !== '') { + const socialId = buildSocialIdString({ type: SocialIdType.EMAIL, value: email }) + const name = get(employeeBySocialKeyStore).get(socialId)?.name + if (name != null) return name + } + return emailOrId } export async function MessageTitleProvider (client: Client, ref: Ref, doc?: Message): Promise { diff --git a/rush.json b/rush.json index fe20065deb..1b1b984c36 100644 --- a/rush.json +++ b/rush.json @@ -2472,6 +2472,11 @@ "packageName": "@hcengineering/model-achievement", "projectFolder": "models/achievement", "shouldPublish": false + }, + { + "packageName": "@hcengineering/kvs-client", + "projectFolder": "packages/kvs-client", + "shouldPublish": false } ] } diff --git a/server/account/src/serviceOperations.ts b/server/account/src/serviceOperations.ts index ad7ad308af..1db6e1b53d 100644 --- a/server/account/src/serviceOperations.ts +++ b/server/account/src/serviceOperations.ts @@ -549,7 +549,7 @@ export async function addSocialIdToPerson ( const { person, type, value, confirmed, displayValue } = params const { extra } = decodeTokenVerbose(ctx, token) - verifyAllowedServices(['github', 'telegram-bot'], extra) + verifyAllowedServices(['github', 'telegram-bot', 'gmail'], extra) return await addSocialId(db, person, type, value, confirmed, displayValue) } @@ -564,7 +564,7 @@ export async function updateSocialId ( const { personId, displayValue } = params const { extra } = decodeTokenVerbose(ctx, token) - verifyAllowedServices(['telegram-bot'], extra) + verifyAllowedServices(['telegram-bot', 'gmail'], extra) const socialId = await db.socialId.findOne({ _id: personId }) if (socialId != null) { @@ -815,7 +815,7 @@ export async function findFullSocialIdBySocialKey ( params: { socialKey: string } ): Promise { const { extra } = decodeTokenVerbose(ctx, token) - verifyAllowedServices(['telegram-bot'], extra) + verifyAllowedServices(['telegram-bot', 'gmail'], extra) const { socialKey } = params diff --git a/server/account/src/utils.ts b/server/account/src/utils.ts index af9174315a..9022f6ac35 100644 --- a/server/account/src/utils.ts +++ b/server/account/src/utils.ts @@ -583,7 +583,7 @@ export async function selectWorkspace ( throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUrl })) } - if (accountUuid !== systemAccountUuid) { + if (accountUuid !== systemAccountUuid && meta !== undefined) { void setTimezoneIfNotDefined(ctx, db, accountUuid, account, meta) } @@ -1475,7 +1475,7 @@ export async function setTimezoneIfNotDefined ( } // Move to config? -export const integrationServices = ['github', 'telegram-bot', 'telegram', 'mailbox', 'caldav'] +export const integrationServices = ['github', 'telegram-bot', 'telegram', 'mailbox', 'caldav', 'gmail'] export async function findExistingIntegration ( account: AccountUuid, diff --git a/services/gmail/pod-gmail/.gitignore b/services/gmail/pod-gmail/.gitignore new file mode 100644 index 0000000000..2eea525d88 --- /dev/null +++ b/services/gmail/pod-gmail/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/services/gmail/pod-gmail/README.md b/services/gmail/pod-gmail/README.md new file mode 100644 index 0000000000..1a21d80016 --- /dev/null +++ b/services/gmail/pod-gmail/README.md @@ -0,0 +1,116 @@ +# Pod Gmail Service + +A service that provides Gmail integration capabilities for the Hardcore Engineering Platform. This service handles Gmail authentication, message synchronization, and workspace management. + +## Overview + +The pod-gmail service is responsible for: +- Managing Gmail client connections +- Synchronizing emails across workspaces +- Handling user authentication and token management +- Providing Gmail integration features for the platform + +## Prerequisites + +- Node.js 16 or higher +- Gmail API credentials +- Hardcore Engineering Platform dependencies + +## Installation + +1. Install dependencies: +```bash +npm install +``` + +2. Configure environment variables: +- Create a `.env` file with necessary credentials +- Configure Gmail API credentials + +## Example .env Configuration + +Below is an example `.env` file for running the service locally: + +```env +PORT=8093 +ACCOUNTS_URL="http://127.0.0.1:3000" +SECRET="secret" +WATCH_TOPIC_NAME="test" +Credentials={"web":{"client_id":"get_from_your_gmail_account","client_secret":"get_from_your_gmail_account","redirect_uris":["http://localhost:8088/signin/code"]}} +MINIO_ENDPOINT="localhost" +MINIO_ACCESS_KEY="minioadmin" +MINIO_SECRET_KEY="minioadmin" +KVS_URL="http://localhost:8098" +``` + +Make sure to replace the values with your actual credentials and configuration. + +## Development + +### Running Locally + +```bash +rushx run-local +``` + +### Building + +```bash +rushx build +``` + +### Testing + +```bash +rushx test +``` + +## Docker Support + +The service can be built using Docker: + +```bash +# Build Docker image +rushx docker:build +``` + +## Configuration + +The service requires the following configurations: +- Gmail API credentials +- Workspace-specific settings +- Rate limiting configurations + +## API Integration + +The service integrates with: +- Gmail API for email operations +- Hardcore Engineering Platform core services +- Server core components + +## Dependencies + +### Core Dependencies +- @hcengineering/core +- @hcengineering/server-core +- @hcengineering/server-client +- @hcengineering/server-token +- @hcengineering/gmail + +### External Dependencies +- googleapis +- google-auth-library +- express +- cors + +## License + +This project is licensed under the Eclipse Public License, Version 2.0. See the LICENSE file for details. + +## Contributing + +Contributions are welcome! Please read our contributing guidelines before submitting pull requests. + +## Support + +For support, please contact the Hardcore Engineering team or open an issue in the repository. \ No newline at end of file diff --git a/services/gmail/pod-gmail/package.json b/services/gmail/pod-gmail/package.json index ac7f8a8b6c..41cc62a0bc 100644 --- a/services/gmail/pod-gmail/package.json +++ b/services/gmail/pod-gmail/package.json @@ -35,6 +35,7 @@ "@types/cors": "^2.8.12", "@types/express": "^4.17.13", "@types/node": "~20.11.16", + "@types/sanitize-html": "^2.15.0", "@typescript-eslint/eslint-plugin": "^6.11.0", "@typescript-eslint/parser": "^6.11.0", "esbuild": "^0.24.2", @@ -50,10 +51,13 @@ "prettier": "^3.1.0", "ts-node-dev": "^2.0.0", "typescript": "^5.3.3", - "@types/uuid": "^8.3.1" + "@types/uuid": "^8.3.1", + "ts-node": "^10.8.0", + "cross-env": "~7.0.3" }, "dependencies": { "@hcengineering/attachment": "^0.6.14", + "@hcengineering/account-client": "^0.6.0", "@hcengineering/client": "^0.6.18", "@hcengineering/client-resources": "^0.6.27", "@hcengineering/contact": "^0.6.24", @@ -66,6 +70,7 @@ "@hcengineering/server-client": "^0.6.0", "@hcengineering/server-storage": "^0.6.0", "@hcengineering/server-token": "^0.6.11", + "@hcengineering/kvs-client": "^0.6.0", "cors": "^2.8.5", "dotenv": "~16.0.0", "express": "^4.21.2", @@ -77,6 +82,7 @@ "jwt-simple": "^0.5.6", "mongodb": "^6.12.0", "uuid": "^8.3.2", - "@hcengineering/analytics-service": "^0.6.0" + "@hcengineering/analytics-service": "^0.6.0", + "sanitize-html": "^2.15.0" } } diff --git a/services/gmail/pod-gmail/src/__mocks__/config.ts b/services/gmail/pod-gmail/src/__mocks__/config.ts new file mode 100644 index 0000000000..fbc6ce1c62 --- /dev/null +++ b/services/gmail/pod-gmail/src/__mocks__/config.ts @@ -0,0 +1,12 @@ +const config = { + Port: 8087, + AccountsURL: 'http://localhost:8080', + ServiceID: 'gmail-service-test', + Secret: 'test-secret', + Credentials: 'test-credentials', + WATCH_TOPIC_NAME: 'test-watch-topic', + FooterMessage: '

Sent via Huly

', + InitLimit: 50 +} + +export default config diff --git a/services/gmail/pod-gmail/src/__tests__/attachments.test.ts b/services/gmail/pod-gmail/src/__tests__/attachments.test.ts new file mode 100644 index 0000000000..def6a84b28 --- /dev/null +++ b/services/gmail/pod-gmail/src/__tests__/attachments.test.ts @@ -0,0 +1,223 @@ +import { + MeasureContext, + Ref, + Blob, + TxOperations, + WorkspaceUuid, + Doc, + Space, + Class, + PersonId +} from '@hcengineering/core' +import { StorageAdapter } from '@hcengineering/server-core' +import { gmail_v1 } from 'googleapis' +import { AttachmentHandler } from '../message/attachments' +import type { AttachedFile } from '../types' +import attachment, { Attachment } from '@hcengineering/attachment' +import { decode64, encode64 } from '../base64' + +jest.mock('../config') + +/* eslint-disable @typescript-eslint/unbound-method */ + +describe('AttachmentHandler', () => { + const mockCtx: MeasureContext = { + contextData: {}, + newChild: jest.fn(), + with: jest.fn(), + withSync: jest.fn(), + withLog: jest.fn(), + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + logOperation: jest.fn(), + childLogger: jest.fn(), + close: jest.fn() + }, + measure: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + end: jest.fn() + } + const mockWorkspaceId = 'test-workspace' as WorkspaceUuid + const mockStorageAdapter = { + put: jest.fn(), + read: jest.fn() + } as unknown as StorageAdapter + const mockGmail = { + messages: { + attachments: { + get: jest.fn() + } + } + } as unknown as gmail_v1.Resource$Users + const mockClient = { + addCollection: jest.fn(), + findAll: jest.fn() + } as unknown as TxOperations + + const getBaseAttachment = (): Attachment => ({ + _id: 'test-attachment' as Ref, + name: 'test.txt', + type: 'text/plain', + file: 'test-file' as Ref, + size: 0, + lastModified: 0, + attachedTo: '' as Ref, + attachedToClass: '' as Ref>, + collection: '', + space: '' as Ref, + modifiedOn: 0, + modifiedBy: '' as PersonId, + _class: '' as Ref> + }) + + let attachmentHandler: AttachmentHandler + + beforeEach(() => { + attachmentHandler = new AttachmentHandler(mockCtx, mockWorkspaceId, mockStorageAdapter, mockGmail, mockClient) + }) + + describe('addAttachement', () => { + it('should not add attachment if it already exists', async () => { + const file: AttachedFile = { + file: 'test-file', + name: 'test.txt', + type: 'text/plain', + size: 100, + lastModified: Date.now() + } + const message = { _id: 'test-message', _class: 'test-class', space: 'test-space' } + const currentAttachments: Attachment[] = [ + { + ...getBaseAttachment(), + name: 'test.txt', + lastModified: file.lastModified + } + ] + + await attachmentHandler.addAttachement(file, message, currentAttachments) + + expect(mockStorageAdapter.put).not.toHaveBeenCalled() + expect(mockClient.addCollection).not.toHaveBeenCalled() + }) + + it('should add new attachment', async () => { + const file: AttachedFile = { + file: 'test-file', + name: 'test.txt', + type: 'text/plain', + size: 100, + lastModified: Date.now() + } + const message = { _id: 'test-message', _class: 'test-class', space: 'test-space' } + const currentAttachments: Attachment[] = [] + + await attachmentHandler.addAttachement(file, message, currentAttachments) + + expect(mockStorageAdapter.put).toHaveBeenCalled() + expect(mockClient.addCollection).toHaveBeenCalledWith( + attachment.class.Attachment, + message.space, + message._id, + message._class, + 'attachments', + expect.any(Object) + ) + }) + }) + + describe('getPartFiles', () => { + it('should return empty array for undefined part', async () => { + const result = await attachmentHandler.getPartFiles(undefined, 'test-message') + expect(result).toEqual([]) + }) + + it('should get files from attachment', async () => { + const part: gmail_v1.Schema$MessagePart = { + filename: 'test.txt', + mimeType: 'text/plain', + body: { + attachmentId: 'test-attachment', + size: 100 + } + } + const mockAttachment = { + data: 'test-data', + size: 100 + } + ;(mockGmail.messages.attachments.get as jest.Mock).mockResolvedValue({ data: mockAttachment }) + + const result = await attachmentHandler.getPartFiles(part, 'test-message') + + expect(result).toEqual([ + { + file: 'test-data', + name: 'test.txt', + type: 'text/plain', + size: 100, + lastModified: expect.any(Number) + } + ]) + }) + + it('should get files from body data', async () => { + const part: gmail_v1.Schema$MessagePart = { + filename: 'test.txt', + mimeType: 'text/plain', + body: { + data: 'test-data', + size: 100 + } + } + + const result = await attachmentHandler.getPartFiles(part, 'test-message') + + expect(result).toEqual([ + { + file: 'test-data', + name: 'test.txt', + type: 'text/plain', + size: 100, + lastModified: expect.any(Number) + } + ]) + }) + }) + + describe('makeAttachmentsBody', () => { + it('should create message body with attachments', async () => { + const message = { + to: 'test@example.com', + subject: 'Test Subject', + content: 'Test Content', + _id: 'test-message' + } + const from = 'from@example.com' + const attachment: Attachment = { + ...getBaseAttachment(), + _id: 'test-attachment' as Ref, + name: 'test.txt', + type: 'text/plain', + file: 'test-file' as Ref + } + const mockAttachments: Attachment[] = [attachment] + ;(mockClient.findAll as jest.Mock).mockResolvedValue(mockAttachments) + ;(mockStorageAdapter.read as jest.Mock).mockResolvedValue([Buffer.from('test-data', 'utf8')]) + + const result = await attachmentHandler.makeAttachmentsBody(message, from) + const decodedResult = decode64(result) + + expect(decodedResult).toContain('Content-Type: multipart/mixed') + expect(decodedResult).toContain('To: test@example.com') + expect(decodedResult).toContain('From: from@example.com') + expect(decodedResult).toContain(`Subject: =?UTF-8?B?${encode64('Test Subject')}?= \n`) + expect(decodedResult).toContain('Test Content') + expect(decodedResult).toContain('test.txt') + expect(decodedResult).toContain('Content-Disposition: attachment; filename="test.txt"') + expect(decodedResult).toContain('

Sent via Huly

') + }) + }) +}) diff --git a/services/gmail/pod-gmail/src/__tests__/gmailClient.test.ts b/services/gmail/pod-gmail/src/__tests__/gmailClient.test.ts new file mode 100644 index 0000000000..a54f0fcca5 --- /dev/null +++ b/services/gmail/pod-gmail/src/__tests__/gmailClient.test.ts @@ -0,0 +1,345 @@ +import { + AccountUuid, + PersonId, + WorkspaceUuid, + type SocialId, + type SocialIdType, + type MeasureContext +} from '@hcengineering/core' + +import { GmailClient } from '../gmail' +import { ProjectCredentials, ProjectCredentialsData, Token, User } from '../types' +import { TokenStorage } from '../tokens' +import { SyncManager } from '../message/sync' + +// Mock dependencies +const mockedGoogleAuth = { + generateAuthUrl: jest.fn().mockReturnValue('https://auth-url.com'), + getToken: jest.fn().mockResolvedValue({ tokens: { access_token: 'new-token' } }), + setCredentials: jest.fn(), + refreshAccessToken: jest.fn().mockResolvedValue({ credentials: { access_token: 'refreshed-token' } }), + revokeCredentials: jest.fn().mockResolvedValue({}) +} + +const mockedGmailUsers = { + getProfile: jest.fn().mockResolvedValue({ data: { emailAddress: 'test@example.com' } }), + messages: { + send: jest.fn().mockResolvedValue({}), + list: jest.fn().mockResolvedValue({ data: { messages: [] } }) + }, + watch: jest.fn().mockResolvedValue({}), + stop: jest.fn().mockResolvedValue({}) +} + +// Mock all imports before they're used +jest.mock('@hcengineering/core', () => { + return { + AccountUuid: String, + PersonId: String, + WorkspaceUuid: String, + // Provide missing properties + configUserAccountUuid: 'test-user-id', + core: { + space: { + Workspace: 'workspace' + } + }, + TxOperations: jest.fn().mockImplementation(() => ({ + findAll: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(undefined), + createDoc: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + remove: jest.fn().mockResolvedValue({}), + updateDoc: jest.fn().mockResolvedValue({}), + tx: jest.fn().mockResolvedValue({}) + })) + } +}) + +jest.mock('googleapis', () => ({ + gmail_v1: {}, + google: { + auth: { + OAuth2: jest.fn().mockImplementation(() => mockedGoogleAuth) + }, + gmail: jest.fn().mockImplementation(() => ({ + users: mockedGmailUsers + })) + } +})) + +jest.mock('../tokens') +jest.mock('../message/message') +jest.mock('../message/sync') +jest.mock('../message/attachments') +jest.mock('@hcengineering/server-client', () => ({ + getAccountClient: jest.fn() +})) +jest.mock('../accounts', () => ({ + getOrCreateSocialId: jest.fn().mockResolvedValue({ _id: 'test-social-id' }) +})) +jest.mock('../gmail/utils', () => ({ + getEmail: jest.fn().mockResolvedValue('test@example.com') +})) +jest.mock('../integrations', () => ({ + createIntegrationIfNotEsixts: jest.fn().mockResolvedValue({ _id: 'test-integration-id' }), + disableIntegration: jest.fn(), + removeIntegration: jest.fn() +})) +jest.mock('../utils', () => ({ + addFooter: jest.fn().mockImplementation((content: string) => content), + isToken: jest.fn().mockImplementation((obj: any) => 'token' in obj), + serviceToken: jest.fn().mockReturnValue('service-token'), + getKvsClient: jest.fn().mockImplementation(() => ({ + getValue: jest.fn(), + setValue: jest.fn().mockImplementation(() => Promise.resolve(undefined)), + deleteKey: jest.fn().mockImplementation(() => Promise.resolve(undefined)), + listKeys: jest.fn() + })) +})) + +// Mock gmail module +jest.mock('@hcengineering/gmail', () => ({ + integrationType: { + Gmail: 'gmail' + }, + class: { + NewMessage: 'class.NewMessage' + } +})) + +// Mock setting module +jest.mock('@hcengineering/setting', () => ({ + class: { + Integration: 'class.Integration' + } +})) + +// Mock config +jest.mock('../config', () => ({ + WATCH_TOPIC_NAME: 'test-topic' +})) + +describe('GmailClient', () => { + const mockContext: MeasureContext = { + info: jest.fn(), + error: jest.fn(), + measure: jest.fn().mockImplementation((name: string, fn: () => any) => fn()), + with: jest.fn().mockImplementation((data: any, fn: () => any) => fn()) + } as any + + const mockWorkspaceId = 'test-workspace' as WorkspaceUuid + const mockUserId = 'test-user-id' as AccountUuid + + // Fix the SocialId type issue + const mockSocialId: SocialId = { + _id: 'test-social-id' as PersonId, + value: 'test@example.com', + type: 'email' as SocialIdType, + key: 'email' + } + + const credentialsData: ProjectCredentialsData = { + client_id: 'client-id', + client_secret: 'client-secret', + redirect_uris: ['https://redirect-uri.com'], + project_id: 'test-project', + auth_uri: 'https://auth-uri.com', + token_uri: 'https://token-uri.com', + auth_provider_x509_cert_url: 'https://cert-url.com' + } + const mockCredentials: ProjectCredentials = { + web: credentialsData + } + + const mockUser: User = { + userId: mockUserId, + workspace: mockWorkspaceId, + socialId: mockSocialId, + email: 'test@example.com', + token: 'user-token' + } + + const mockToken: Token = { + userId: mockUserId, + workspace: mockWorkspaceId, + socialId: mockSocialId, + token: 'test-token', + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + scope: 'test-scope', + token_type: 'Bearer', + expiry_date: 123456789 + } + + const mockClient = { + findAll: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(undefined), + createDoc: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + remove: jest.fn().mockResolvedValue({}), + updateDoc: jest.fn().mockResolvedValue({}), + tx: jest.fn().mockResolvedValue({}) + } + + const mockWorkspaceClient = { + subscribeMessages: jest.fn().mockResolvedValue({}), + signoutBySocialId: jest.fn().mockResolvedValue({}) + } + + const mockStorageAdapter = {} + + beforeEach(() => { + jest.clearAllMocks() + // Fix unbound method errors by using the proper mock assignment approach + jest.spyOn(TokenStorage.prototype, 'getToken').mockResolvedValue(mockToken) + jest.spyOn(TokenStorage.prototype, 'saveToken').mockResolvedValue({} as any) + jest.spyOn(TokenStorage.prototype, 'deleteToken').mockResolvedValue() + jest.spyOn(SyncManager.prototype, 'sync').mockResolvedValue() + }) + + it('should create a GmailClient instance', async () => { + const client = await GmailClient.create( + mockContext as any, + mockCredentials, + mockUser, + mockClient as any, + mockWorkspaceClient as any, + mockWorkspaceId, + mockStorageAdapter as any + ) + + expect(client).toBeInstanceOf(GmailClient) + expect(TokenStorage).toHaveBeenCalled() + expect(mockContext.info).toHaveBeenCalledWith('Created gmail client', expect.any(Object)) + }) + + it('should create a GmailClient instance with auth code', async () => { + const client = await GmailClient.create( + mockContext as any, + mockCredentials, + mockUser, + mockClient as any, + mockWorkspaceClient as any, + mockWorkspaceId, + mockStorageAdapter as any, + 'auth-code' + ) + + expect(client).toBeInstanceOf(GmailClient) + expect(mockedGoogleAuth.setCredentials).toHaveBeenCalled() + expect(mockContext.info).toHaveBeenCalledWith('Created gmail client', expect.any(Object)) + }) + + it('should generate auth URL', async () => { + const client = await GmailClient.create( + mockContext as any, + mockCredentials, + mockUser, + mockClient as any, + mockWorkspaceClient as any, + mockWorkspaceId, + mockStorageAdapter as any + ) + + const authUrl = client.getAuthUrl('https://callback-url.com') + expect(authUrl).toBe('https://auth-url.com') + }) + + it('should sign out and clean up resources', async () => { + const client = await GmailClient.create( + mockContext as any, + mockCredentials, + mockUser, + mockClient as any, + mockWorkspaceClient as any, + mockWorkspaceId, + mockStorageAdapter as any + ) + + await client.signout() + + // Fix unbound method reference by using the spy + expect(jest.spyOn(TokenStorage.prototype, 'deleteToken')).toHaveBeenCalledWith(mockSocialId._id) + expect(mockContext.info).toHaveBeenCalledWith('Deactivate gmail client', expect.any(Object)) + }) + + it('should create a message', async () => { + const client = await GmailClient.create( + mockContext as any, + mockCredentials, + mockUser, + mockClient as any, + mockWorkspaceClient as any, + mockWorkspaceId, + mockStorageAdapter as any + ) + + const newMessage = { + _id: 'test-message-id', + _class: 'class.NewMessage', + space: 'space', + status: 'new', + from: mockSocialId._id, + to: 'recipient@example.com', + subject: 'Test Subject', + content: '

Test content

' + } + + // Mock internal TxOperations instance + const mockTxOperations = { + findAll: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(undefined), + createDoc: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + remove: jest.fn().mockResolvedValue({}), + updateDoc: jest.fn().mockResolvedValue({}), + tx: jest.fn().mockResolvedValue({}) + } + + // Access and replace the private TxOperations instance + Object.defineProperty(client, 'client', { + value: mockTxOperations, + writable: true + }) + + await client.createMessage(newMessage as any) + + expect(mockTxOperations.updateDoc).toHaveBeenCalledWith('class.NewMessage', 'space', 'test-message-id', { + status: 'sent' + }) + }) + + it('should sync messages', async () => { + const client = await GmailClient.create( + mockContext as any, + mockCredentials, + mockUser, + mockClient as any, + mockWorkspaceClient as any, + mockWorkspaceId, + mockStorageAdapter as any + ) + + await client.sync() + + // Fix unbound method reference by using the spy + expect(jest.spyOn(SyncManager.prototype, 'sync')).toHaveBeenCalledWith(mockSocialId._id, 'test@example.com') + }) + + it('should initialize integration', async () => { + const client = await GmailClient.create( + mockContext as any, + mockCredentials, + mockUser, + mockClient as any, + mockWorkspaceClient as any, + mockWorkspaceId, + mockStorageAdapter as any + ) + + await client.initIntegration() + + expect(mockContext.info).toHaveBeenCalledWith('Init integration', expect.any(Object)) + }) +}) diff --git a/services/gmail/pod-gmail/src/__tests__/message.test.ts b/services/gmail/pod-gmail/src/__tests__/message.test.ts new file mode 100644 index 0000000000..62f7e3b830 --- /dev/null +++ b/services/gmail/pod-gmail/src/__tests__/message.test.ts @@ -0,0 +1,227 @@ +import { type MeasureContext, PersonId, TxOperations, AttachedData } from '@hcengineering/core' +import { type GaxiosResponse } from 'gaxios' +import { gmail_v1 } from 'googleapis' +import { type Message } from '@hcengineering/gmail' + +import { type Channel } from '../types' +import { AttachmentHandler } from '../message/attachments' +import { MessageManager, sanitizeText } from '../message/message' + +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +/* eslint-disable @typescript-eslint/unbound-method */ + +jest.mock('../config') + +describe('MessageManager', () => { + let messageManager: MessageManager + let mockCtx: MeasureContext + let mockClient: TxOperations + let mockAttachmentHandler: AttachmentHandler + let mockSocialId: PersonId + let mockWorkspace: { getChannel: (email: string) => Channel | undefined } + + beforeEach(() => { + mockCtx = { + measure: jest.fn(), + with: jest.fn() + } as unknown as MeasureContext + + mockClient = { + findOne: jest.fn(), + findAll: jest.fn(), + tx: jest.fn() + } as unknown as TxOperations + + mockAttachmentHandler = { + getPartFiles: jest.fn(), + addAttachement: jest.fn() + } as unknown as AttachmentHandler + + mockSocialId = 'test-social-id' as PersonId + + mockWorkspace = { + getChannel: jest.fn() + } + + messageManager = new MessageManager(mockCtx, mockClient, mockAttachmentHandler, mockSocialId, mockWorkspace) + }) + + describe('saveMessage', () => { + const createMockGmailResponse = (): GaxiosResponse => ({ + config: {}, + data: { + id: 'test-message-id', + internalDate: '1234567890', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'To', value: 'recipient@example.com' }, + { name: 'Message-ID', value: 'test-message-id' }, + { name: 'Subject', value: 'Test Subject' } + ], + parts: [ + { + mimeType: 'text/plain', + body: { data: 'dGVzdCBjb250ZW50' } + }, + { + mimeType: 'text/html', + body: { data: 'PHA+dGVzdCBjb250ZW50PC9wPg==' } + } + ] + } + }, + status: 200, + statusText: 'OK', + headers: {}, + request: { + responseURL: 'https://example.com' + } + }) + + const createMockMessage = (): AttachedData => ({ + messageId: 'test-message-id', + textContent: 'test content', + subject: 'Test Subject', + content: 'test content', + sendOn: 1234567890, + from: 'sender@example.com', + to: 'recipient@example.com', + copy: [], + incoming: true + }) + + it('should save message with attachments', async () => { + const mockMessage = createMockGmailResponse() + const mockChannel = { _id: 'test-channel-id' } as Channel + mockWorkspace.getChannel = jest.fn().mockReturnValue(mockChannel) + mockClient.findOne = jest.fn().mockResolvedValue(undefined) + mockAttachmentHandler.getPartFiles = jest.fn().mockResolvedValue([]) + + await messageManager.saveMessage(mockMessage, 'test@example.com') + + expect(mockClient.tx).toHaveBeenCalled() + expect(mockAttachmentHandler.getPartFiles).toHaveBeenCalled() + }) + + it('should update existing message', async () => { + const mockMessage = createMockGmailResponse() + const mockChannel = { _id: 'test-channel-id' } as Channel + const existingMessage = createMockMessage() + mockWorkspace.getChannel = jest.fn().mockReturnValue(mockChannel) + mockClient.findOne = jest.fn().mockResolvedValue(existingMessage) + mockAttachmentHandler.getPartFiles = jest.fn().mockResolvedValue([]) + + await messageManager.saveMessage(mockMessage, 'test@example.com') + + expect(mockClient.tx).toHaveBeenCalled() + expect(mockAttachmentHandler.getPartFiles).toHaveBeenCalled() + }) + + it('should not save message when no channels found', async () => { + const mockMessage = createMockGmailResponse() + mockWorkspace.getChannel = jest.fn().mockReturnValue(undefined) + + await messageManager.saveMessage(mockMessage, 'test@example.com') + + expect(mockClient.tx).not.toHaveBeenCalled() + expect(mockAttachmentHandler.getPartFiles).not.toHaveBeenCalled() + }) + + it('should handle incoming messages correctly', async () => { + const mockMessage = createMockGmailResponse() + const mockChannel = { _id: 'test-channel-id' } as Channel + mockWorkspace.getChannel = jest.fn().mockReturnValue(mockChannel) + mockClient.findOne = jest.fn().mockResolvedValue(undefined) + mockAttachmentHandler.getPartFiles = jest.fn().mockResolvedValue([]) + + await messageManager.saveMessage(mockMessage, 'recipient@example.com') + + expect(mockWorkspace.getChannel).toHaveBeenCalledWith('sender@example.com') + }) + + it('should handle outgoing messages correctly', async () => { + const mockMessage = createMockGmailResponse() + const mockChannel = { _id: 'test-channel-id' } as Channel + mockWorkspace.getChannel = jest.fn().mockReturnValue(mockChannel) + mockClient.findOne = jest.fn().mockResolvedValue(undefined) + mockAttachmentHandler.getPartFiles = jest.fn().mockResolvedValue([]) + + await messageManager.saveMessage(mockMessage, 'sender@example.com') + + expect(mockWorkspace.getChannel).toHaveBeenCalledWith('recipient@example.com') + }) + }) +}) + +describe('sanitizeHtml', () => { + it('should remove all HTML tags', () => { + const html = '

This is bold and italic text

' + const expected = 'This is bold and italic text' + expect(sanitizeText(html)).toBe(expected) + }) + + it('should handle nested tags properly', () => { + const html = '

Nested tags should be removed

' + const expected = 'Nested tags should be removed' + expect(sanitizeText(html)).toBe(expected) + }) + + it('should handle malformed HTML', () => { + const html = 'Text with paragraph

' + const expected = 'Text with paragraph' + expect(sanitizeText(html)).toBe(expected) + }) + + it('should handle scripts and potential XSS attacks', () => { + const html = 'Hello world' + const expected = 'Hello world' + expect(sanitizeText(html)).toBe(expected) + }) + + it('should remove style tags and attributes', () => { + const html = '

Styled text

' + const expected = 'Styled text' + expect(sanitizeText(html)).toBe(expected) + }) + + it('should handle empty input', () => { + expect(sanitizeText('')).toBe('') + }) +}) + +describe('sanitizeText', () => { + it('should remove all HTML tags', () => { + const html = '

This is bold and italic text

' + const expected = 'This is bold and italic text' + expect(sanitizeText(html)).toBe(expected) + }) + + it('should handle nested tags properly', () => { + const html = '

Nested tags should be removed

' + const expected = 'Nested tags should be removed' + expect(sanitizeText(html)).toBe(expected) + }) + + it('should handle malformed HTML', () => { + const html = 'Text with paragraph

' + const expected = 'Text with paragraph' + expect(sanitizeText(html)).toBe(expected) + }) + + it('should handle scripts and potential XSS attacks', () => { + const html = 'Hello world' + const expected = 'Hello world' + expect(sanitizeText(html)).toBe(expected) + }) + + it('should remove style tags and attributes', () => { + const html = '

Styled text

' + const expected = 'Styled text' + expect(sanitizeText(html)).toBe(expected) + }) + + it('should handle empty input', () => { + expect(sanitizeText('')).toBe('') + }) +}) diff --git a/services/gmail/pod-gmail/src/__tests__/sync.test.ts b/services/gmail/pod-gmail/src/__tests__/sync.test.ts new file mode 100644 index 0000000000..81e47709ca --- /dev/null +++ b/services/gmail/pod-gmail/src/__tests__/sync.test.ts @@ -0,0 +1,337 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { MeasureContext, PersonId } from '@hcengineering/core' +import { SyncManager, SyncMutex } from '../message/sync' +import { MessageManager } from '../message/message' +import { RateLimiter } from '../rateLimiter' + +jest.mock('../config') +jest.mock('../message/message') +jest.mock('../utils', () => { + const originalModule = jest.requireActual('../utils') + return { + ...originalModule, + getKvsClient: jest.fn().mockImplementation(() => mockKeyValueClientInstance) + } +}) + +// Create mock types +interface MockKeyValueClient { + getValue: jest.Mock + setValue: jest.Mock + deleteKey: jest.Mock + listKeys: jest.Mock +} + +// Create a shared mock instance that can be referenced in the mock above +const mockKeyValueClientInstance = { + getValue: jest.fn(), + setValue: jest.fn().mockImplementation(() => Promise.resolve(undefined)), + deleteKey: jest.fn().mockImplementation(() => Promise.resolve(undefined)), + listKeys: jest.fn() +} + +describe('SyncManager', () => { + // Mocked dependencies + let mockCtx: MeasureContext + let mockMessageManager: jest.Mocked + let mockGmail: any // Using any for easier mocking + let mockKeyValueClient: MockKeyValueClient + + // Test constants + const workspace = 'test-workspace' + const userId = 'user1' as PersonId + const userEmail = 'user1@example.com' + const historyId = '12345' + + // Initialize SyncManager instance for each test + let syncManager: SyncManager + + beforeEach(() => { + // Setup mocks + mockCtx = { + info: jest.fn(), + error: jest.fn() + } as unknown as jest.Mocked + + mockMessageManager = { + saveMessage: jest.fn().mockResolvedValue(undefined) + } as unknown as jest.Mocked + + mockGmail = { + history: { + list: jest.fn().mockImplementation(() => + Promise.resolve({ + data: { + history: [], + nextPageToken: undefined + } + }) + ) + }, + messages: { + list: jest.fn().mockImplementation(() => + Promise.resolve({ + data: { + messages: [], + nextPageToken: undefined + } + }) + ), + get: jest.fn().mockImplementation(() => + Promise.resolve({ + data: { + id: 'test-id', + historyId + } + }) + ) + } + } + + mockKeyValueClient = { + getValue: jest.fn(), + setValue: jest.fn().mockImplementation(() => Promise.resolve(undefined)), + deleteKey: jest.fn().mockImplementation(() => Promise.resolve(undefined)), + listKeys: jest.fn() + } + + // Create SyncManager instance + syncManager = new SyncManager( + mockCtx, + mockMessageManager, + mockGmail, + workspace, + mockKeyValueClient as any, + new RateLimiter(100, 100) + ) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('getHistory', () => { + it('should retrieve history from key-value store', async () => { + // Arrange + const mockHistory = { + historyId, + userId, + workspace + } + mockKeyValueClient.getValue.mockResolvedValue(mockHistory) + + // Act + const result = await (syncManager as any).getHistory(userId) + + // Assert + expect(mockKeyValueClient.getValue).toHaveBeenCalledWith(`history:${workspace}:${userId}`) + expect(result).toEqual(mockHistory) + }) + + it('should return null when history not found', async () => { + // Arrange + mockKeyValueClient.getValue.mockResolvedValue(null) + + // Act + const result = await (syncManager as any).getHistory(userId) + + // Assert + expect(result).toBeNull() + }) + }) + + describe('setHistoryId', () => { + it('should store history in key-value store', async () => { + // Act + await (syncManager as any).setHistoryId(userId, historyId) + + // Assert + expect(mockKeyValueClient.setValue).toHaveBeenCalledWith(`history:${workspace}:${userId}`, { + historyId, + userId, + workspace + }) + }) + }) + + describe('clearHistory', () => { + it('should delete history from key-value store', async () => { + // Act + await (syncManager as any).clearHistory(userId) + + // Assert + expect(mockKeyValueClient.deleteKey).toHaveBeenCalledWith(`history:${workspace}:${userId}`) + }) + }) + + describe('sync', () => { + it('should perform full sync when no history exists', async () => { + // Arrange + mockKeyValueClient.getValue.mockResolvedValue(null) + const spyFullSync = jest.spyOn(syncManager, 'fullSync').mockResolvedValue(undefined) + + // Act + await syncManager.sync(userId, userEmail) + + // Assert + expect(spyFullSync).toHaveBeenCalledWith(userId, userEmail) + }) + + it('should perform part sync when history exists', async () => { + // Arrange + mockKeyValueClient.getValue.mockResolvedValue({ + historyId, + userId, + workspace + }) + + // Act + await syncManager.sync(userId, userEmail) + + // Assert + expect(mockGmail.history.list).toHaveBeenCalledWith({ + userId: 'me', + historyTypes: ['messageAdded'], + startHistoryId: historyId, + pageToken: undefined + }) + }) + }) + + describe('getMessage', () => { + it('should retrieve a message by ID', async () => { + // Arrange + const messageId = 'msg123' + const mockResponse = { + data: { + id: messageId, + historyId + } + } + + mockGmail.messages.get.mockImplementation(() => Promise.resolve(mockResponse)) + + // Act + const result = await (syncManager as any).getMessage(messageId) + + // Assert + expect(mockGmail.messages.get).toHaveBeenCalledWith({ + id: messageId, + userId: 'me', + format: 'FULL' + }) + expect(result).toEqual(mockResponse) + }) + }) +}) + +describe('SyncMutex', () => { + let syncMutex: SyncMutex + + beforeEach(() => { + syncMutex = new SyncMutex() + }) + + it('should allow sequential locking and unlocking', async () => { + // Lock first time + const release1 = await syncMutex.lock('test-key') + release1() + + // Lock second time + const release2 = await syncMutex.lock('test-key') + release2() + + // If we got here without hanging, the test passes + expect(true).toBe(true) + }) + + it('should queue up multiple requests for the same key', async () => { + const results: number[] = [] + + // Start 3 concurrent lock operations + const promise1 = (async () => { + const release = await syncMutex.lock('test-key') + results.push(1) + await new Promise((resolve) => setTimeout(resolve, 10)) + release() + })() + + const promise2 = (async () => { + const release = await syncMutex.lock('test-key') + results.push(2) + await new Promise((resolve) => setTimeout(resolve, 5)) + release() + })() + + const promise3 = (async () => { + const release = await syncMutex.lock('test-key') + results.push(3) + release() + })() + + // Wait for all promises to resolve + await Promise.all([promise1, promise2, promise3]) + + // The operations should have happened in order + expect(results).toEqual([1, 2, 3]) + }) + + it('should allow concurrent operations on different keys', async () => { + const results: string[] = [] + + // Lock two different keys concurrently + const promise1 = (async () => { + const release = await syncMutex.lock('key1') + results.push('key1-locked') + await new Promise((resolve) => setTimeout(resolve, 20)) + results.push('key1-unlocked') + release() + })() + + const promise2 = (async () => { + const release = await syncMutex.lock('key2') + results.push('key2-locked') + await new Promise((resolve) => setTimeout(resolve, 10)) + results.push('key2-unlocked') + release() + })() + + // Wait for both promises to resolve + await Promise.all([promise1, promise2]) + + // key2 operations should complete before key1 due to shorter timeout + expect(results.indexOf('key2-locked')).toBeLessThan(results.indexOf('key1-unlocked')) + }) + + it('should release the lock properly even if an error occurs', async () => { + try { + const release = await syncMutex.lock('test-key') + try { + throw new Error('Test error') + } finally { + release() + } + } catch (error) { + // Ignore the error + } + + // Should be able to acquire the lock again + const release = await syncMutex.lock('test-key') + release() + + expect(true).toBe(true) + }) +}) diff --git a/services/gmail/pod-gmail/src/__tests__/tokens.test.ts b/services/gmail/pod-gmail/src/__tests__/tokens.test.ts new file mode 100644 index 0000000000..cd2357af96 --- /dev/null +++ b/services/gmail/pod-gmail/src/__tests__/tokens.test.ts @@ -0,0 +1,139 @@ +import { AccountUuid, PersonId, WorkspaceUuid } from '@hcengineering/core' +import { AccountClient, IntegrationSecret } from '@hcengineering/account-client' +import { getAccountClient } from '@hcengineering/server-client' +import { TokenStorage } from '../tokens' +import { GMAIL_INTEGRATION, SecretType, Token } from '../types' +import type { MeasureContext } from '@hcengineering/core' + +// Mock the getAccountClient function +jest.mock('@hcengineering/server-client', () => ({ + getAccountClient: jest.fn() +})) + +describe('TokenStorage', () => { + const mockSocialId = 'test-social-id' as PersonId + const mockWorkspace = 'test-workspace' as WorkspaceUuid + const mockPlatformToken = 'test-platform-token' + const mockUserId = 'test-user' as AccountUuid + const mockToken: Token = { + userId: mockUserId, + socialId: { _id: '123' } as any, + workspace: mockWorkspace, + token: 'test-token', + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + scope: 'test-scope', + token_type: 'Bearer', + expiry_date: 123456789 + } + + let tokenStorage: TokenStorage + let mockAccountClient: jest.Mocked + // Add mock MeasureContext + const mockMeasureContext: MeasureContext = { + info: jest.fn(), + error: jest.fn() + } as any + + beforeEach(() => { + // Create mock AccountClient + mockAccountClient = { + getIntegrationSecret: jest.fn(), + addIntegrationSecret: jest.fn(), + updateIntegrationSecret: jest.fn(), + deleteIntegrationSecret: jest.fn() + } as any + + // Set up getAccountClient mock + ;(getAccountClient as jest.Mock).mockReturnValue(mockAccountClient) + + // Create TokenStorage instance with MeasureContext + tokenStorage = new TokenStorage(mockMeasureContext, mockWorkspace, mockPlatformToken) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('getToken', () => { + it('should return null when no token exists', async () => { + mockAccountClient.getIntegrationSecret.mockResolvedValue(null) + const result = await tokenStorage.getToken(mockSocialId) + expect(result).toBeNull() + expect(mockAccountClient.getIntegrationSecret).toHaveBeenCalledWith({ + key: SecretType.TOKEN, + kind: GMAIL_INTEGRATION, + socialId: mockSocialId, + workspaceUuid: mockWorkspace + }) + }) + + it('should return parsed token when token exists', async () => { + const mockSecret: IntegrationSecret = { + socialId: mockSocialId, + kind: GMAIL_INTEGRATION, + workspaceUuid: mockWorkspace, + key: SecretType.TOKEN, + secret: JSON.stringify(mockToken) + } + mockAccountClient.getIntegrationSecret.mockResolvedValue(mockSecret) + const result = await tokenStorage.getToken(mockSocialId) + expect(result).toEqual(mockToken) + }) + }) + + describe('saveToken', () => { + it('should update token when it exists', async () => { + const existingToken = { existing: 'token' } + const mockSecret: IntegrationSecret = { + socialId: mockSocialId, + kind: GMAIL_INTEGRATION, + workspaceUuid: mockWorkspace, + key: SecretType.TOKEN, + secret: JSON.stringify(existingToken) + } + mockAccountClient.getIntegrationSecret.mockResolvedValue(mockSecret) + await tokenStorage.saveToken(mockSocialId, mockToken) + + // Expect merged token (existing token properties + new token properties) + const expectedMergedToken = { + ...existingToken, + ...mockToken + } + + expect(mockAccountClient.updateIntegrationSecret).toHaveBeenCalledWith({ + key: SecretType.TOKEN, + kind: GMAIL_INTEGRATION, + socialId: mockSocialId, + secret: JSON.stringify(expectedMergedToken), + workspaceUuid: mockWorkspace + }) + expect(mockAccountClient.addIntegrationSecret).not.toHaveBeenCalled() + }) + + it('should add new token when it does not exist', async () => { + mockAccountClient.getIntegrationSecret.mockResolvedValue(null) + await tokenStorage.saveToken(mockSocialId, mockToken) + expect(mockAccountClient.addIntegrationSecret).toHaveBeenCalledWith({ + key: SecretType.TOKEN, + kind: GMAIL_INTEGRATION, + socialId: mockSocialId, + secret: JSON.stringify(mockToken), + workspaceUuid: mockWorkspace + }) + expect(mockAccountClient.updateIntegrationSecret).not.toHaveBeenCalled() + }) + }) + + describe('deleteToken', () => { + it('should delete the token', async () => { + await tokenStorage.deleteToken(mockSocialId) + expect(mockAccountClient.deleteIntegrationSecret).toHaveBeenCalledWith({ + key: SecretType.TOKEN, + kind: GMAIL_INTEGRATION, + socialId: mockSocialId, + workspaceUuid: mockWorkspace + }) + }) + }) +}) diff --git a/services/gmail/pod-gmail/src/accounts.ts b/services/gmail/pod-gmail/src/accounts.ts new file mode 100644 index 0000000000..93a8f99ada --- /dev/null +++ b/services/gmail/pod-gmail/src/accounts.ts @@ -0,0 +1,74 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { AccountUuid, Person, PersonId, SocialId, SocialIdType, buildSocialIdString } from '@hcengineering/core' +import { getAccountClient } from '@hcengineering/server-client' +import { generateToken } from '@hcengineering/server-token' + +import { serviceToken } from './utils' + +export async function getAccountPerson (account: AccountUuid): Promise { + try { + const accountClient = getAccountClient(generateToken(account)) + return await accountClient.getPerson() + } catch (e) { + console.error(e) + } + return undefined +} + +export async function getAccountSocialIds (account: AccountUuid): Promise { + try { + const accountClient = getAccountClient(generateToken(account)) + return await accountClient.getSocialIds() + } catch (e) { + console.error(e) + } + return [] +} + +export async function getOrCreateSocialIdByEmail ( + token: string, + account: AccountUuid, + email: string +): Promise { + const client = getAccountClient(token) + const socialId = await client.findFullSocialIdBySocialKey( + buildSocialIdString({ type: SocialIdType.EMAIL, value: email }) + ) + if (socialId != null) return socialId._id + return await client.addSocialIdToPerson(account, SocialIdType.EMAIL, email, true) +} + +export async function getOrCreateSocialId (account: AccountUuid, email: string): Promise { + const accountClient = getAccountClient(serviceToken()) + let socialId = await accountClient.findFullSocialIdBySocialKey( + buildSocialIdString({ type: SocialIdType.EMAIL, value: email }) + ) + if (socialId == null) { + await accountClient.addSocialIdToPerson(account, SocialIdType.EMAIL, email, true) + socialId = await accountClient.findFullSocialIdBySocialKey( + buildSocialIdString({ type: SocialIdType.EMAIL, value: email }) + ) + if (socialId == null) { + throw new Error('Cannot create social id') + } + } + + if (socialId.personUuid !== account) { + throw new Error('Social id connected to another account') + } + + return socialId +} diff --git a/services/gmail/pod-gmail/src/config.ts b/services/gmail/pod-gmail/src/config.ts index 870f660a19..df14ab4134 100644 --- a/services/gmail/pod-gmail/src/config.ts +++ b/services/gmail/pod-gmail/src/config.ts @@ -13,12 +13,12 @@ // See the License for the specific language governing permissions and // limitations under the License. // +import { config as dotenvConfig } from 'dotenv' + +dotenvConfig() interface Config { Port: number - - MongoURI: string - MongoDB: string AccountsURL: string ServiceID: string Secret: string @@ -26,21 +26,19 @@ interface Config { WATCH_TOPIC_NAME: string FooterMessage: string InitLimit: number + KvsUrl: string } const envMap: { [key in keyof Config]: string } = { Port: 'PORT', - - MongoURI: 'MONGO_URI', - MongoDB: 'MONGO_DB', - AccountsURL: 'ACCOUNTS_URL', ServiceID: 'SERVICE_ID', Secret: 'SECRET', Credentials: 'Credentials', WATCH_TOPIC_NAME: 'WATCH_TOPIC_NAME', FooterMessage: 'FOOTER_MESSAGE', - InitLimit: 'INIT_LIMIT' + InitLimit: 'INIT_LIMIT', + KvsUrl: 'KVS_URL' } const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined) @@ -48,15 +46,14 @@ const parseNumber = (str: string | undefined): number | undefined => (str !== un const config: Config = (() => { const params: Partial = { Port: parseNumber(process.env[envMap.Port]) ?? 8087, - MongoDB: process.env[envMap.MongoDB] ?? 'gmail-service', - MongoURI: process.env[envMap.MongoURI], AccountsURL: process.env[envMap.AccountsURL], ServiceID: process.env[envMap.ServiceID] ?? 'gmail-service', Secret: process.env[envMap.Secret], Credentials: process.env[envMap.Credentials], WATCH_TOPIC_NAME: process.env[envMap.WATCH_TOPIC_NAME], InitLimit: parseNumber(process.env[envMap.InitLimit]) ?? 50, - FooterMessage: process.env[envMap.FooterMessage] ?? '

Sent via Huly

' + FooterMessage: process.env[envMap.FooterMessage] ?? '

Sent via Huly

', + KvsUrl: process.env[envMap.KvsUrl] } const missingEnv = (Object.keys(params) as Array) diff --git a/services/gmail/pod-gmail/src/gmail.ts b/services/gmail/pod-gmail/src/gmail.ts index d0b425fa99..0db65ef703 100644 --- a/services/gmail/pod-gmail/src/gmail.ts +++ b/services/gmail/pod-gmail/src/gmail.ts @@ -13,44 +13,30 @@ // limitations under the License. // -import attachment, { Attachment } from '@hcengineering/attachment' -import core, { - AttachedData, - Blob, - Client, - Data, - MeasureContext, - Ref, - Timestamp, - TxCreateDoc, - TxCUD, - TxFactory, - TxOperations, - TxProcessor, - TxUpdateDoc, - WorkspaceUuid -} from '@hcengineering/core' -import gmail, { type Message, type NewMessage } from '@hcengineering/gmail' +import core, { AccountUuid, Client, MeasureContext, SocialId, TxOperations, WorkspaceUuid } from '@hcengineering/core' +import gmail, { type NewMessage } from '@hcengineering/gmail' import { type StorageAdapter } from '@hcengineering/server-core' import setting from '@hcengineering/setting' -import type { GaxiosResponse } from 'gaxios' import type { Credentials, OAuth2Client } from 'google-auth-library' import { gmail_v1, google } from 'googleapis' -import type { Collection, Db } from 'mongodb' -import { v4 as uuid } from 'uuid' -import { arrayBufferToBase64, decode64, encode64 } from './base64' +import { encode64 } from './base64' import config from './config' import { GmailController } from './gmailController' import { RateLimiter } from './rateLimiter' -import type { AttachedFile, Channel, History, ProjectCredentials, Token, User } from './types' -import { diffAttributes, isToken } from './utils' +import type { ProjectCredentials, Token, User } from './types' +import { addFooter, isToken, serviceToken, getKvsClient } from './utils' import type { WorkspaceClient } from './workspaceClient' +import { getOrCreateSocialId } from './accounts' +import { createIntegrationIfNotEsixts, disableIntegration, removeIntegration } from './integrations' +import { AttachmentHandler } from './message/attachments' +import { TokenStorage } from './tokens' +import { MessageManager } from './message/message' +import { SyncManager } from './message/sync' +import { getEmail } from './gmail/utils' +import { Integration } from '@hcengineering/account-client' const SCOPES = ['https://www.googleapis.com/auth/gmail.modify'] -const EMAIL_REGEX = - /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/ - function makeHTMLBody (message: NewMessage, from: string): string { const str = [ 'Content-Type: text/html; charset="UTF-8"\n', @@ -78,65 +64,6 @@ function makeHTMLBody (message: NewMessage, from: string): string { return encode64(res).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } -function addFooter (message: string): string { - if (config.FooterMessage === undefined || config.FooterMessage.trim() === '') return message - return message + config.FooterMessage.trim() -} - -function convertMessage ( - message: GaxiosResponse, - me: string -): AttachedData & { modifiedOn: Timestamp } { - const date = message.data.internalDate != null ? new Date(Number.parseInt(message.data.internalDate)) : new Date() - const from = getHeaderValue(message.data.payload, 'From') ?? '' - const to = getHeaderValue(message.data.payload, 'To') ?? '' - const copy = - getHeaderValue(message.data.payload, 'Cc') - ?.split(',') - .map((p) => p.trim()) ?? undefined - const incoming = !from.includes(me) - return { - modifiedOn: date.getTime(), - messageId: getHeaderValue(message.data.payload, 'Message-ID') ?? '', - replyTo: getHeaderValue(message.data.payload, 'In-Reply-To'), - copy, - content: getPartMessage(message.data.payload, 'text/html'), - textContent: getPartMessage(message.data.payload, 'text/plain').replace(/<[^>]*>/g, ''), - from, - to, - incoming, - subject: getHeaderValue(message.data.payload, 'Subject') ?? '', - sendOn: date.getTime() - } -} - -function getHeaderValue (payload: gmail_v1.Schema$MessagePart | undefined, name: string): string | undefined { - if (payload === undefined) return undefined - const headers = payload.headers - - return headers?.find((header) => header.name?.toLowerCase() === name.toLowerCase())?.value ?? undefined -} - -function getPartsMessage (parts: gmail_v1.Schema$MessagePart[] | undefined, mime: string): string { - let result = '' - if (parts !== undefined) { - const htmlPart = parts.find((part) => part.mimeType === mime) - const filtredParts = htmlPart !== undefined ? parts.filter((part) => part.mimeType === mime) : parts - for (const part of filtredParts ?? []) { - result += getPartMessage(part, mime) - } - } - return result -} - -function getPartMessage (part: gmail_v1.Schema$MessagePart | undefined, mime: string): string { - if (part === undefined) return '' - if (part.body?.data != null) { - return decode64(part.body.data) - } - return getPartsMessage(part.parts, mime) -} - async function wait (sec: number): Promise { await new Promise((resolve) => { setTimeout(() => { @@ -146,56 +73,116 @@ async function wait (sec: number): Promise { } export class GmailClient { - private readonly oAuth2Client: OAuth2Client - private readonly gmail: gmail_v1.Resource$Users - private me: string | undefined = undefined - private readonly tokens: Collection - private readonly histories: Collection + private readonly account: AccountUuid + private email: string | undefined = undefined + private readonly tokenStorage: TokenStorage private readonly client: TxOperations private watchTimer: NodeJS.Timeout | undefined = undefined private refreshTimer: NodeJS.Timeout | undefined = undefined - private syncPromise: Promise | undefined = undefined - private readonly rateLimiter = new RateLimiter(1000, 200) // in fact 250, but let's make reserve + private readonly rateLimiter = new RateLimiter(1000, 200) + private readonly attachmentHandler: AttachmentHandler + private readonly messageManager: MessageManager + private readonly syncManager: SyncManager + private readonly integrationToken: string + private integration: Integration | undefined = undefined private constructor ( private readonly ctx: MeasureContext, - credentials: ProjectCredentials, + private readonly oAuth2Client: OAuth2Client, + private readonly gmail: gmail_v1.Resource$Users, private readonly user: User, - mongo: Db, client: Client, - private readonly workspaceId: WorkspaceUuid, - private readonly storageAdapter: StorageAdapter, - private readonly workspace: WorkspaceClient + workspaceId: WorkspaceUuid, + storageAdapter: StorageAdapter, + private readonly workspace: WorkspaceClient, + email: string, + private socialId: SocialId ) { - const { client_secret, client_id, redirect_uris } = credentials.web // eslint-disable-line - this.oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]) // eslint-disable-line - this.gmail = google.gmail({ version: 'v1', auth: this.oAuth2Client }).users - this.tokens = mongo.collection('tokens') - this.histories = mongo.collection('histories') - this.client = new TxOperations(client, this.user.userId) + this.email = email + this.integrationToken = serviceToken() + this.tokenStorage = new TokenStorage(this.ctx, workspaceId, this.integrationToken) + this.client = new TxOperations(client, this.socialId._id) + this.account = this.user.userId + this.attachmentHandler = new AttachmentHandler(ctx, workspaceId, storageAdapter, this.gmail, this.client) + this.messageManager = new MessageManager( + ctx, + this.client, + this.attachmentHandler, + this.socialId._id, + this.workspace + ) + const keyValueClient = getKvsClient(this.integrationToken) + this.syncManager = new SyncManager( + ctx, + this.messageManager, + this.gmail, + this.user.workspace, + keyValueClient, + this.rateLimiter + ) } static async create ( ctx: MeasureContext, credentials: ProjectCredentials, user: User | Token, - mongo: Db, client: Client, workspace: WorkspaceClient, workspaceId: WorkspaceUuid, - storageAdapter: StorageAdapter + storageAdapter: StorageAdapter, + authCode?: string ): Promise { - const gmailClient = new GmailClient(ctx, credentials, user, mongo, client, workspaceId, storageAdapter, workspace) - if (isToken(user)) { - console.log('Setting token while creating', user.workspace, user.userId, user) - await gmailClient.setToken(user) + // eslint-disable-next-line @typescript-eslint/naming-convention + const { client_secret, client_id, redirect_uris } = credentials.web + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]) + const googleClient = google.gmail({ version: 'v1', auth: oAuth2Client }).users + + let token: Credentials | undefined = isToken(user) ? user : undefined + if (token === undefined && authCode !== undefined) { + const tokenResponse = await oAuth2Client.getToken(authCode) + token = { + ...user, + ...tokenResponse.tokens + } + oAuth2Client.setCredentials(token) + } else if (token !== undefined) { + oAuth2Client.setCredentials(token) + } + const email = user.email ?? (await getEmail(googleClient)) + if (email === undefined) { + throw new Error('Cannot retrieve user email') + } + const socialId = user.socialId ?? (await getOrCreateSocialId(user.userId, email)) + if (socialId?._id == null) { + throw new Error(`Cannot create gmail client without social id: ${user.userId}, ${workspaceId}`) + } + user.socialId = socialId + + const gmailClient = new GmailClient( + ctx, + oAuth2Client, + googleClient, + user, + client, + workspaceId, + storageAdapter, + workspace, + email, + socialId + ) + await gmailClient.createIntregration() + if (token !== undefined) { + ctx.info('Setting token while creating', { workspaceUuid: user.workspace, userId: user.userId }) + await gmailClient.setToken(token) await gmailClient.refreshToken() await gmailClient.addClient() } + ctx.info('Created gmail client', { workspaceUuid: user.workspace, userId: user.userId }) + void gmailClient.initIntegration() return gmailClient } - getAutUrl (redirectURL: string): string { + getAuthUrl (redirectURL: string): string { const { workspace, userId } = this.user const authUrl = this.oAuth2Client.generateAuthUrl({ access_type: 'offline', @@ -205,42 +192,54 @@ export class GmailClient { return authUrl } - async authorize (code: string): Promise { - const token = await this.oAuth2Client.getToken(code) - console.log('Setting token after authorize', this.user.workspace, this.user.userId, token) - await this.setToken(token.tokens) - await this.refreshToken() - await this.addClient() - void this.startSync() - void this.getNewMessagesAfterAuth() + async initIntegration (): Promise { + try { + this.ctx.info('Init integration', { workspaceUuid: this.user.workspace, userId: this.user.userId }) - const me = await this.getMe() - const integrations = await this.client.findAll(setting.class.Integration, { - createdBy: this.user.userId, - type: gmail.integrationType.Gmail - }) - const updated = integrations.find((p) => p.disabled && p.value === me) + void this.startSync() + void this.getNewMessagesAfterAuth() - for (const integration of integrations.filter((p) => p._id !== updated?._id)) { - await this.client.remove(integration) + const integrations = await this.client.findAll(setting.class.Integration, { + createdBy: this.socialId._id, + type: gmail.integrationType.Gmail + }) + const updated = integrations.find((p) => p.disabled && p.value === this.email) + + for (const integration of integrations.filter((p) => p._id !== updated?._id)) { + await this.client.remove(integration) + } + if (updated !== undefined) { + await this.client.update(updated, { + disabled: false + }) + } else { + await this.client.createDoc(setting.class.Integration, core.space.Workspace, { + type: gmail.integrationType.Gmail, + disabled: false, + value: this.socialId._id + }) + } + } catch (err: any) { + this.ctx.info('Failed to start message sync', { + workspaceUuid: this.user.workspace, + userId: this.user.userId, + error: err.message + }) } - if (updated !== undefined) { - await this.client.update(updated, { - disabled: false - }) - } else { - await this.client.createDoc(setting.class.Integration, core.space.Workspace, { - type: gmail.integrationType.Gmail, - disabled: false, - value: me - }) + } + + async createIntregration (): Promise { + try { + this.integration = await createIntegrationIfNotEsixts(this.socialId._id, this.user.workspace) + } catch (err: any) { + this.ctx.error('Failed to create integration', { socialdId: this.socialId, workspace: this.workspace }) } } async getNewMessagesAfterAuth (): Promise { const newMessages = await this.client.findAll(gmail.class.NewMessage, { status: 'new', - from: this.user.userId + from: this.socialId._id }) await this.workspace.subscribeMessages() for (const message of newMessages) { @@ -251,11 +250,12 @@ export class GmailClient { async createMessage (message: NewMessage): Promise { if (message.status === 'sent') return try { - const me = await this.getMe() + this.ctx.info('Send gmail message', { id: message._id, from: message.from, to: message.to }) + const email = await this.getEmail() const body = message.attachments != null && message.attachments > 0 - ? await this.makeAttachmentsBody(message, me) - : makeHTMLBody(message, me) + ? await this.makeAttachmentsBody(message, email) + : makeHTMLBody(message, email) await this.rateLimiter.take(100) await this.gmail.messages.send({ userId: 'me', @@ -272,37 +272,31 @@ export class GmailClient { status: 'error', error: JSON.stringify(err) }) - console.log('Create message error', this.user.workspace, this.user.userId, err.message) + this.ctx.error('Create message error', { + workspaceUuid: this.user.workspace, + userId: this.user.userId, + message: err.message + }) if (err?.response?.data?.error === 'invalid_grant') { await this.refreshToken() } } } - private async checkErrorWithSignout (err: any): Promise { - if (this.checkError(err)) { - console.log('Invalid grant error', this.user.workspace, this.user.userId) - await this.workspace.signoutByUserId(this.user.userId, true) - return true - } - return false - } - private checkError (err: any): boolean { return err?.response?.data?.error === 'invalid_grant' } async signout (byError: boolean = false): Promise { + this.ctx.info('Deactivate gmail client', { socialId: this.socialId, workspace: this.user.workspace }) try { await this.close() await this.oAuth2Client.revokeCredentials() } catch {} - await this.tokens.deleteOne({ - userId: this.user.userId, - workspace: this.user.workspace - }) + await this.tokenStorage.deleteToken(this.socialId._id) + await this.disableIntegration(byError) const integration = await this.client.findOne(setting.class.Integration, { - createdBy: this.user.userId, + createdBy: this.socialId._id, type: gmail.integrationType.Gmail }) if (integration !== undefined) { @@ -314,26 +308,19 @@ export class GmailClient { } } - async sync (): Promise { - if (this.syncPromise !== undefined) { - await this.syncPromise - } - const history = await this.getHistory() - try { - if (history != null) { - this.syncPromise = this.partSync(history.historyId) - await this.syncPromise + async disableIntegration (byError: boolean = false): Promise { + if (this.integration !== undefined) { + if (byError) { + await disableIntegration(this.integration) } else { - this.syncPromise = this.fullSync() - await this.syncPromise + await removeIntegration(this.socialId._id, this.user.workspace) } - } finally { - this.syncPromise = undefined } } async startSync (): Promise { - await this.sync() + this.ctx.info('Start sync', { workspaceUuid: this.user.workspace, userId: this.user.userId, email: this.email }) + await this.syncManager.sync(this.socialId._id, this.email) await this.watch() // recall every 24 hours https://developers.google.com/gmail/api/guides/push this.watchTimer = setInterval(() => { @@ -341,64 +328,72 @@ export class GmailClient { }, 86400000) } + async sync (): Promise { + this.ctx.info('Sync', { workspaceUuid: this.user.workspace, userId: this.user.userId }) + await this.syncManager.sync(this.socialId._id, this.email) + } + async newChannel (value: string): Promise { const query = `{from:${value} to:${value} cc:${value} bcc:${value}}` await this.fullSync(query) } - private async getMe (): Promise { - if (this.me !== undefined) { - return this.me + private async getEmail (): Promise { + if (this.email !== undefined) { + return this.email } for (let i = 0; i < 3; i++) { await this.rateLimiter.take(1) const profile = await this.gmail.getProfile({ userId: 'me' }) - this.me = profile.data.emailAddress ?? undefined - if (this.me !== undefined) return this.me + this.email = profile.data.emailAddress ?? undefined + if (this.email !== undefined) return this.email await wait(5) } throw new Error("Can't get profile email after 3 attempts") } - private async getCurrentToken (): Promise { - return await this.tokens.findOne({ - userId: this.user.userId, - workspace: this.user.workspace - }) + private async syncUserInfo (credentials: Credentials): Promise { + if (credentials.access_token == null) return + if (this.email === undefined) { + this.ctx.error('User email is missing', { workspaceUuid: this.user.workspace, userId: this.user.userId }) + return + } + this.socialId = await getOrCreateSocialId(this.account, this.email) } - private async updateCurrentToken (token: Credentials): Promise { - await this.tokens.updateOne( - { - userId: this.user.userId, - workspace: this.user.workspace - }, - { - $set: { - ...token - } - } - ) + private async getCurrentToken (): Promise { + return await this.tokenStorage.getToken(this.socialId._id) } private async addClient (): Promise { try { - const me = await this.getMe() + this.ctx.info('Register client', { socialId: this.socialId._id, email: this.email }) const controller = GmailController.getGmailController() - controller.addClient(me, this) + controller.addClient(this.socialId._id, this) } catch (err) { - console.log('Add client error', this.user.workspace, this.user.userId, (err as any).message) + this.ctx.error('Add client error', { + workspaceUuid: this.user.workspace, + userId: this.user.userId, + message: (err as any).message + }) } } private async setToken (token: Credentials): Promise { try { this.oAuth2Client.setCredentials(token) - await this.getMe() + const email = await this.getEmail() + await this.syncUserInfo(token) + await this.updateSocialId(email) + await this.updateToken(token) } catch (err: any) { - console.log('Set token error', this.user.workspace, this.user.userId, err.message) + this.ctx.error('Set token error', { + workspaceUuid: this.user.workspace, + userId: this.user.userId, + message: err.message + }) if (this.checkError(err)) { await this.signout(true) } @@ -406,338 +401,31 @@ export class GmailClient { } } + private async updateSocialId (email: string): Promise { + const existingEmail = this.socialId !== undefined ? this.socialId.value : undefined + if (existingEmail === email) return + this.socialId = await getOrCreateSocialId(this.user.userId, email) + } + private async updateToken (token: Credentials): Promise { try { - const currentToken = await this.getCurrentToken() - if (currentToken != null) { - await this.updateCurrentToken(token) - } else { - await this.tokens.insertOne({ - userId: this.user.userId, - workspace: this.user.workspace, - token: this.user.token, - ...token - }) - } + await this.tokenStorage.saveToken(this.socialId._id, token as Token) } catch (err) { - console.log('update token error', this.user.workspace, this.user.userId, (err as any).message) - } - } - - private async getHistory (): Promise { - return await this.histories.findOne({ - userId: this.user.userId, - workspace: this.user.workspace - }) - } - - private async clearHistory (): Promise { - await this.histories.deleteOne({ - userId: this.user.userId, - workspace: this.user.workspace - }) - } - - private async setHistoryId (historyId: string): Promise { - await this.histories.updateOne( - { + this.ctx.error('update token error', { + workspaceUuid: this.user.workspace, userId: this.user.userId, - workspace: this.user.workspace - }, - { - $set: { - historyId - } - }, - { upsert: true } - ) - } - - private async partSync (historyId: string): Promise { - let pageToken: string | undefined - let histories: GaxiosResponse - while (true) { - try { - await this.rateLimiter.take(2) - histories = await this.gmail.history.list({ - userId: 'me', - historyTypes: ['messageAdded'], - startHistoryId: historyId, - pageToken - }) - } catch (err) { - console.log('Part sync get history error', this.user.workspace, this.user.userId, err) - const signout = await this.checkErrorWithSignout(err) - if (signout) return - await this.clearHistory() - await this.sync() - return - } - const nextPageToken = histories.data.nextPageToken - const array = histories.data.history ?? [] - try { - for (const history of array) { - for (const message of history.messagesAdded ?? []) { - if (message.message?.id == null || (message.message.labelIds?.includes('DRAFT') ?? false)) { - continue - } - try { - const res = await this.getMessage(message.message.id) - await this.saveMessage(res) - } catch (err) { - console.log('Part sync message error', this.user.workspace, this.user.userId, message.message.id, err) - const signout = await this.checkErrorWithSignout(err) - if (signout) return - } - } - if (history.id != null) { - await this.setHistoryId(history.id) - } - } - if (nextPageToken == null) { - return - } else { - pageToken = nextPageToken - } - } catch (err) { - console.log('Part sync error', this.user.workspace, this.user.userId, err) - return - } + message: (err as any).message + }) } } private async fullSync (q?: string): Promise { - const pageToken: string | undefined = undefined - const query: gmail_v1.Params$Resource$Users$Messages$List = { - userId: 'me', - pageToken - } - if (q !== undefined) { - query.q = q - } - let currentHistoryId: string | undefined - console.log('start full sync', this.user.workspace, this.user.userId, q) - try { - const messagesIds: string[] = [] - while (true) { - await this.rateLimiter.take(5) - const messages = await this.gmail.messages.list(query) - if (query.pageToken == null) { - console.log('Total messages', this.user.workspace, this.user.userId, messages.data.resultSizeEstimate) - } - const ids = messages.data.messages?.map((p) => p.id) ?? [] - for (let index = 0; index < ids.length; index++) { - const id = ids[index] - if (id == null) continue - messagesIds.push(id) - } - if (messages.data.nextPageToken == null) { - console.log('Break, total new messages: ', messagesIds.length) - break - } - query.pageToken = messages.data.nextPageToken - } - for (let index = messagesIds.length - 1; index >= 0; index--) { - const id = messagesIds[index] - try { - const message = await this.getMessage(id) - const historyId = message.data.historyId - await this.saveMessage(message) - if (historyId != null && q === undefined) { - if (currentHistoryId == null || Number(currentHistoryId) < Number(historyId)) { - await this.setHistoryId(historyId) - currentHistoryId = historyId - } - } - } catch (err: any) { - console.log('Full sync message error', this.user.workspace, this.user.userId, err) - const signout = await this.checkErrorWithSignout(err) - if (signout) return - } - } - } catch (err) { - console.log('Full sync error', this.user.workspace, this.user.userId, err) - await this.checkErrorWithSignout(err) - } - } - - private async getMessage (id: string): Promise> { - await this.rateLimiter.take(5) - return await this.gmail.messages.get({ - id, - userId: 'me', - format: 'FULL' + this.ctx.info('Full sync client', { + workspaceUuid: this.user.workspace, + userId: this.user.userId, + email: this.email }) - } - - private findChannels (res: AttachedData): Channel[] { - const recievers = res.copy != null ? [res.to, ...res.copy] : [res.to] - const values = res.incoming ? [res.from] : recievers - const result: Channel[] = [] - for (const value of values) { - const res = value.match(EMAIL_REGEX) - if (res !== null) { - const email = res[0] - const channel = this.workspace.getChannel(email) - if (channel !== undefined) result.push(channel) - } - } - return result - } - - private async saveMessage (message: GaxiosResponse): Promise { - const me = await this.getMe() - const res = convertMessage(message, me) - const channels = this.findChannels(res) - if (channels.length === 0) return - const attachments = await this.getPartFiles(message.data.payload, message.data.id ?? '') - const factory = new TxFactory(this.user.userId) - for (const channel of channels) { - const current = await this.client.findOne(gmail.class.Message, { - messageId: res.messageId, - attachedTo: channel._id - }) - const tx = current != null ? this.updateTx(res, current, factory, channel) : this.createTx(res, factory, channel) - if (tx !== undefined) { - const resultMessage = - current != null - ? TxProcessor.updateDoc2Doc(current, tx as TxUpdateDoc) - : TxProcessor.createDoc2Doc(tx as TxCreateDoc) - await this.client.tx(tx) - if (attachments.length > 0) { - const currentAttachemtns: Attachment[] = - current !== undefined - ? await this.client.findAll(attachment.class.Attachment, { attachedTo: current._id }) - : [] - for (const attachment of attachments) { - await this.addAttachement(attachment, resultMessage, currentAttachemtns) - } - } - } - } - } - - private async addAttachement (file: AttachedFile, message: Message, currentAttachemtns: Attachment[]): Promise { - try { - if (currentAttachemtns.findIndex((p) => p.name === file.name && p.lastModified === file.lastModified) !== -1) { - return - } - const id = uuid() - const data: AttachedData = { - name: file.name, - file: id as Ref, - type: file.type ?? 'undefined', - size: file.size ?? Buffer.from(file.file, 'base64').length, - lastModified: file.lastModified - } - await this.storageAdapter.put(this.ctx, this.workspaceId as any, id, file.file, data.type, data.size) // TODO: FIXME - await this.client.addCollection( - attachment.class.Attachment, - message.space, - message._id, - message._class, - 'attachments', - data - ) - } catch (err: any) { - console.log('Add attachment error', this.user.workspace, this.user.userId, err.message) - } - } - - private async getPartFiles ( - part: gmail_v1.Schema$MessagePart | undefined, - messageId: string - ): Promise { - if (part === undefined) return [] - if (part.filename != null && part.filename.length > 0) { - if (part.body?.attachmentId != null) { - const attachment = await this.getAttachmentById(part.body?.attachmentId, messageId) - if (attachment.data == null) return [] - return [ - { - file: attachment.data, - name: part.filename, - type: part.mimeType ?? undefined, - size: attachment.size ?? undefined, - lastModified: new Date().getTime() - } - ] - } - if (part.body?.data == null) return [] - return [ - { - file: part.body.data, - name: part.filename, - type: part.mimeType ?? undefined, - size: part.body.size ?? undefined, - lastModified: new Date().getTime() - } - ] - } - return await this.getPartsFiles(part.parts, messageId) - } - - private async getAttachmentById (attachmentId: string, messageId: string): Promise { - await this.rateLimiter.take(5) - const res = await this.gmail.messages.attachments.get({ id: attachmentId, userId: 'me', messageId }) - return res.data - } - - private async getPartsFiles ( - parts: gmail_v1.Schema$MessagePart[] | undefined, - messageId: string - ): Promise { - const result: AttachedFile[] = [] - if (parts !== undefined) { - const filtredParts = parts.filter((part) => part.filename != null && part.filename.length > 0) - for (const part of filtredParts ?? []) { - const res = await this.getPartFiles(part, messageId) - if (res.length > 0) { - result.push(...res) - } - } - } - return result - } - - private createTx ( - message: AttachedData & { modifiedOn: Timestamp }, - factory: TxFactory, - channel: Channel - ): TxCUD { - const tx = factory.createTxCollectionCUD( - channel._class, - channel._id, - channel.space, - 'items', - factory.createTxCreateDoc( - gmail.class.Message, - core.space.Workspace, - message as unknown as Data, - undefined, - message.modifiedOn - ), - message.modifiedOn - ) - return tx - } - - private updateTx ( - message: AttachedData & { modifiedOn: Timestamp }, - current: Message, - factory: TxFactory, - channel: Channel - ): TxCUD | undefined { - const operations = diffAttributes(current, message) - if (Object.keys(operations).length === 0) return undefined - const tx = factory.createTxCollectionCUD( - channel._class, - channel._id, - channel.space, - 'items', - factory.createTxUpdateDoc(current._class, current.space, current._id, operations) - ) - return tx + await this.syncManager.fullSync(this.socialId._id, this.email, q) } private async refreshToken (): Promise { @@ -752,9 +440,13 @@ export class GmailClient { 30 * 60 * 1000 ) } catch (err: any) { - console.log("Couldn't refresh token, error:", err.message) + this.ctx.error("Couldn't refresh token", { + workspaceUuid: this.user.workspace, + userId: this.user.userId, + message: err.message + }) if (err?.response?.data?.error === 'invalid_grant') { - await this.workspace.signoutByUserId(this.user.userId, true) + await this.workspace.signoutBySocialId(this.socialId._id, true) } else { this.refreshTimer = setTimeout( () => { @@ -777,62 +469,21 @@ export class GmailClient { } }) } catch (err) { - console.log('Watch error', (err as any).message) + this.ctx.error('Watch error', { + workspaceUuid: this.user.workspace, + userId: this.user.userId, + message: (err as any).message + }) } } private async makeAttachmentsBody (message: NewMessage, from: string): Promise { - const str = [ - 'Content-Type: multipart/mixed; boundary="mail"\n', - 'MIME-Version: 1.0\n', - `To: ${message.to} \n`, - `From: ${from} \n` - ] - - if (message.replyTo != null) { - str.push(`In-Reply-To: ${message.replyTo} \n`) + try { + return await this.attachmentHandler.makeAttachmentsBody(message, from) + } catch (err: any) { + this.ctx.error('Failed to make attachment body', err) + return makeHTMLBody(message, from) } - - if (message.copy != null && message.copy.length > 0) { - str.push(`Cc: ${message.copy.join(', ')} \n`) - } - - if (message.subject != null) { - str.push(`Subject: =?UTF-8?B?${encode64(message.subject)}?= \n`) - } - - str.push('\n\n') - str.push('--mail\n') - str.push('Content-Type: text/html; charset="UTF-8"\n') - str.push('MIME-Version: 1.0\n') - str.push('Content-Transfer-Encoding: 7bit\n\n') - str.push(addFooter(message.content)) - str.push('\n\n') - - const attachments = await this.client.findAll(attachment.class.Attachment, { attachedTo: message._id }) - - for (const attachment of attachments) { - const attachmentStrings = await this.makeAttachmentPart(attachment) - str.push(...attachmentStrings) - } - - str.push('--mail--') - - return encode64(str.join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') - } - - private async makeAttachmentPart (attachment: Attachment): Promise { - const buffer = await this.storageAdapter.read(this.ctx, this.workspaceId as any, attachment.file) // TODO: FIXME - const data = arrayBufferToBase64(Buffer.concat(buffer)) - const res: string[] = [] - res.push('--mail\n') - res.push(`Content-Type: ${attachment.type}\n`) - res.push('MIME-Version: 1.0\n') - res.push('Content-Transfer-Encoding: base64\n') - res.push(`Content-Disposition: attachment; filename="${attachment.name}"\n\n`) - res.push(data) - res.push('\n\n') - return res } async close (): Promise { @@ -844,7 +495,11 @@ export class GmailClient { userId: 'me' }) } catch (err) { - console.log('close error', (err as any).message) + this.ctx.error('close error', { + workspaceUuid: this.user.workspace, + userId: this.user.userId, + message: (err as any).message + }) } } } diff --git a/services/gmail/pod-gmail/src/gmail/auth.ts b/services/gmail/pod-gmail/src/gmail/auth.ts new file mode 100644 index 0000000000..4b70e692ef --- /dev/null +++ b/services/gmail/pod-gmail/src/gmail/auth.ts @@ -0,0 +1,46 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { google } from 'googleapis' +import { OAuth2Client } from 'google-auth-library' +import { ProjectCredentials } from '../types' + +export class AuthProvider { + private oAuth2Client: OAuth2Client | undefined + private readonly credentials: ProjectCredentials + + constructor (credentials: ProjectCredentials) { + this.credentials = credentials + this.initClient() + } + + initClient (): void { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { client_secret, client_id, redirect_uris } = this.credentials.web + this.oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]) + } + + getAuthUrl (redirectURL: string, user: { workspace: string, userId: string }): string { + const { workspace, userId } = user + if (this.oAuth2Client === undefined) { + throw new Error('OAuth client not initialized') + } + const authUrl = this.oAuth2Client.generateAuthUrl({ + access_type: 'offline', + scope: ['https://www.googleapis.com/auth/gmail.modify'], + state: Buffer.from(JSON.stringify({ workspace, userId, redirectURL })).toString('base64') + }) + return authUrl + } +} diff --git a/services/gmail/pod-gmail/src/gmail/utils.ts b/services/gmail/pod-gmail/src/gmail/utils.ts new file mode 100644 index 0000000000..49e3361d7d --- /dev/null +++ b/services/gmail/pod-gmail/src/gmail/utils.ts @@ -0,0 +1,28 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { wait } from '../utils' +import { gmail_v1 } from 'googleapis' + +export async function getEmail (gmail: gmail_v1.Resource$Users): Promise { + for (let i = 0; i < 3; i++) { + const profile = await gmail.getProfile({ + userId: 'me' + }) + const email = profile.data.emailAddress ?? undefined + if (email !== undefined) return email + await wait(5) + } + throw new Error("Can't get profile email after 3 attempts") +} diff --git a/services/gmail/pod-gmail/src/gmailController.ts b/services/gmail/pod-gmail/src/gmailController.ts index e900f59612..6b39b3cf68 100644 --- a/services/gmail/pod-gmail/src/gmailController.ts +++ b/services/gmail/pod-gmail/src/gmailController.ts @@ -13,41 +13,52 @@ // limitations under the License. // -import { isActiveMode, MeasureContext, RateLimiter, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core' +import { + AccountUuid, + isActiveMode, + MeasureContext, + RateLimiter, + WorkspaceUuid, + type PersonId +} from '@hcengineering/core' import type { StorageAdapter } from '@hcengineering/server-core' -import { type Db } from 'mongodb' import { decode64 } from './base64' import config from './config' import { type GmailClient } from './gmail' import { type ProjectCredentials, type Token, type User } from './types' import { WorkspaceClient } from './workspaceClient' -import { generateToken } from '@hcengineering/server-token' import { getAccountClient } from '@hcengineering/server-client' +import { getIntegrations } from './integrations' +import { serviceToken } from './utils' +import { getWorkspaceTokens } from './tokens' + +import { AuthProvider } from './gmail/auth' export class GmailController { private readonly workspaces: Map = new Map() private readonly credentials: ProjectCredentials - private readonly clients: Map = new Map() + private readonly clients: Map = new Map() private readonly initLimitter = new RateLimiter(config.InitLimit) + private readonly authProvider protected static _instance: GmailController private constructor ( private readonly ctx: MeasureContext, - private readonly mongo: Db, private readonly storageAdapter: StorageAdapter ) { this.credentials = JSON.parse(config.Credentials) + this.authProvider = new AuthProvider(this.credentials) GmailController._instance = this } - static create (ctx: MeasureContext, mongo: Db, storageAdapter: StorageAdapter): GmailController { + static create (ctx: MeasureContext, storageAdapter: StorageAdapter): GmailController { if (GmailController._instance !== undefined) { throw new Error('GmailController already exists') } - return new GmailController(ctx, mongo, storageAdapter) + return new GmailController(ctx, storageAdapter) } static getGmailController (): GmailController { @@ -58,45 +69,43 @@ export class GmailController { } async startAll (): Promise { - const tokens = await this.mongo.collection('tokens').find().toArray() - const groups = new Map() - console.log('start gmail service', tokens.length) - for (const token of tokens) { - const group = groups.get(token.workspace) - if (group === undefined) { - groups.set(token.workspace, [token]) - } else { - group.push(token) - groups.set(token.workspace, group) - } - } + try { + const token = serviceToken() + const integrations = await getIntegrations(token) + this.ctx.info('Start integrations', { count: integrations.length }) - const limiter = new RateLimiter(config.InitLimit) - for (const [workspace, tokens] of groups) { - await limiter.add(async () => { - const wstok = generateToken(systemAccountUuid, workspace as any) // TODO: FIXME - const accountClient = getAccountClient(wstok) - const info = await accountClient.getWorkspaceInfo() + const limiter = new RateLimiter(config.InitLimit) + for (const integration of integrations) { + if (integration.workspaceUuid === null) continue + await limiter.add(async () => { + if (integration.workspaceUuid === null) return + const accountClient = getAccountClient(token) + const info = await accountClient.getWorkspaceInfo() - if (info === undefined) { - console.log('workspace not found', workspace) - return - } - if (!isActiveMode(info.mode)) { - console.log('workspace is not active', workspace) - return - } - const startPromise = this.startWorkspace(workspace as any, tokens) // TODO: FIXME - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => { - resolve() - }, 60000) + if (info === undefined) { + this.ctx.info('workspace not found', { workspaceUuid: integration.workspaceUuid }) + return + } + if (!isActiveMode(info.mode)) { + this.ctx.info('workspace is not active', { workspaceUuid: integration.workspaceUuid }) + return + } + const tokens = await getWorkspaceTokens(accountClient, integration.workspaceUuid) + this.ctx.info('Use stored tokens', { count: tokens.length }) + const startPromise = this.startWorkspace(integration.workspaceUuid, tokens) + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + resolve() + }, 60000) + }) + await Promise.race([startPromise, timeoutPromise]) }) - await Promise.race([startPromise, timeoutPromise]) - }) - } + } - await limiter.waitProcessing() + await limiter.waitProcessing() + } catch (err: any) { + this.ctx.error('Failed to start existing integrations', err) + } } async startWorkspace (workspace: WorkspaceUuid, tokens: Token[]): Promise { @@ -105,15 +114,20 @@ export class GmailController { for (const token of tokens) { try { const timeout = setTimeout(() => { - console.log('init client hang', token.workspace, token.userId) + this.ctx.info('init client hang', { workspaceUuid: token.workspace, userId: token.userId }) }, 60000) const client = await workspaceClient.createGmailClient(token) clearTimeout(timeout) clients.push(client) - } catch (err) { - console.error(`Couldn't create client for ${workspace} ${token.userId}`) + } catch (err: any) { + this.ctx.error("Couldn't create client", { + workspaceUuid: workspace, + userId: token.userId, + message: err.message + }) } } + this.ctx.info('Start clients sync', { workspaceUuid: workspace, count: clients.length }) for (const client of clients) { void this.initLimitter.add(async () => { await client.startSync() @@ -133,25 +147,30 @@ export class GmailController { } } - addClient (email: string, client: GmailClient): void { - const clients = this.clients.get(email) + addClient (socialId: PersonId, client: GmailClient): void { + const clients = this.clients.get(socialId) if (clients === undefined) { - this.clients.set(email, [client]) + this.clients.set(socialId, [client]) } else { clients.push(client) - this.clients.set(email, clients) + this.clients.set(socialId, clients) } } - async getGmailClient (email: string, workspace: WorkspaceUuid, token: string): Promise { + /* + async getGmailClient (userId: AccountUuid, workspace: WorkspaceUuid, token: string): Promise { const workspaceClient = await this.getWorkspaceClient(workspace) - const userId = await workspaceClient.getUserId(email) return await workspaceClient.createGmailClient({ userId, workspace, token }) } + */ - async signout (workspace: WorkspaceUuid, email: string): Promise { + getAuthProvider (): AuthProvider { + return this.authProvider + } + + async signout (workspace: WorkspaceUuid, account: AccountUuid): Promise { const workspaceClient = await this.getWorkspaceClient(workspace) - const clients = await workspaceClient.signout(email) + const clients = await workspaceClient.signoutByAccountId(account) if (clients === 0) { this.workspaces.delete(workspace) } @@ -164,9 +183,9 @@ export class GmailController { this.workspaces.clear() } - async createClient (user: User | Token): Promise { + async createClient (user: User | Token, authCode?: string): Promise { const workspace = await this.getWorkspaceClient(user.workspace) - const newClient = await workspace.createGmailClient(user) + const newClient = await workspace.createGmailClient(user, authCode) return newClient } @@ -174,12 +193,12 @@ export class GmailController { let res = this.workspaces.get(workspace) if (res === undefined) { try { - console.log('create workspace worker for', workspace) - res = await WorkspaceClient.create(this.ctx, this.credentials, this.mongo, this.storageAdapter, workspace) + this.ctx.info('create workspace worker', { workspaceUuid: workspace }) + res = await WorkspaceClient.create(this.ctx, this.credentials, this.storageAdapter, workspace) this.workspaces.set(workspace, res) - console.log('created workspace worker for', workspace) + this.ctx.info('created workspace worker', { workspaceUuid: workspace }) } catch (err) { - console.error(`Couldn't create workspace worker for ${workspace}, reason: `, err) + this.ctx.error("Couldn't create workspace worker", { workspaceUuid: workspace, reason: err }) throw err } } diff --git a/services/gmail/pod-gmail/src/integrations.ts b/services/gmail/pod-gmail/src/integrations.ts new file mode 100644 index 0000000000..8dc0b92902 --- /dev/null +++ b/services/gmail/pod-gmail/src/integrations.ts @@ -0,0 +1,132 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { AccountUuid, MeasureContext, PersonId, TxOperations, WorkspaceUuid } from '@hcengineering/core' +import { getAccountClient } from '@hcengineering/server-client' +import { Integration } from '@hcengineering/account-client' +import gmail from '@hcengineering/gmail' +import setting from '@hcengineering/setting' + +import { serviceToken } from './utils' +import { GMAIL_INTEGRATION } from './types' +import { getAccountSocialIds } from './accounts' + +export async function getIntegration (socialId: PersonId, workspaceUuid?: WorkspaceUuid): Promise { + const client = getAccountClient(serviceToken()) + return await client.getIntegration({ kind: GMAIL_INTEGRATION, socialId, workspaceUuid: workspaceUuid ?? null }) +} + +export async function getIntegrations (token: string): Promise { + const client = getAccountClient(token) + return (await client.listIntegrations({ kind: GMAIL_INTEGRATION })) ?? [] +} + +export async function getIntegrationByAccount ( + account: AccountUuid, + workspaceUuid?: WorkspaceUuid +): Promise { + const token = serviceToken() + const client = getAccountClient(token) + const integrations = await client.listIntegrations({ kind: GMAIL_INTEGRATION, workspaceUuid: workspaceUuid ?? null }) + if (integrations.length === 0) return null + const socialIds = await getAccountSocialIds(account) + + for (const integration of integrations) { + if (integration.workspaceUuid == null) continue + const socialId = socialIds.find((it) => it._id === integration.socialId) + if (socialId !== undefined) { + return integration + } + } + + return null +} + +export async function removeIntegration ( + socialId: PersonId | undefined | null, + workspaceUuid: WorkspaceUuid +): Promise { + if (socialId == null) return + const accountClient = getAccountClient(serviceToken()) + const integrations = await accountClient.listIntegrations({ kind: GMAIL_INTEGRATION, socialId, workspaceUuid }) + for (const integration of integrations) { + await accountClient.deleteIntegration({ + socialId, + kind: GMAIL_INTEGRATION, + workspaceUuid: integration.workspaceUuid + }) + } +} + +export async function deleteIntegration (integration: Integration): Promise { + const accountClient = getAccountClient(serviceToken()) + await accountClient.deleteIntegration(integration) +} + +export async function createIntegrationIfNotEsixts (socialId: PersonId, workspace: WorkspaceUuid): Promise { + const accountClient = getAccountClient(serviceToken()) + const integration = { + socialId, + kind: GMAIL_INTEGRATION, + workspaceUuid: workspace + } + const existingIntegration = await accountClient.getIntegration(integration) + if (existingIntegration != null) return existingIntegration + + await accountClient.createIntegration(integration) + return integration +} + +export async function disableIntegration (integration: Integration): Promise { + const client = getAccountClient(serviceToken()) + await client.updateIntegration({ + ...integration, + data: { + disabled: true + } + }) +} + +export async function enableIntegration (integration: Integration): Promise { + const client = getAccountClient(serviceToken()) + await client.updateIntegration({ + ...integration, + data: { + disabled: false + } + }) +} + +export async function cleanIntegrations ( + ctx: MeasureContext, + client: TxOperations, + userId: AccountUuid, + workspace: WorkspaceUuid +): Promise { + const integration = await getIntegrationByAccount(userId, workspace) + if (integration != null) { + await deleteIntegration(integration) + ctx.info('Deleted integration', { userId, workspace }) + } + const personIds = ((await getAccountSocialIds(userId)) ?? []).map((id) => id._id) + const integrationSetting = await client.findOne(setting.class.Integration, { + createdBy: { $in: personIds }, + type: gmail.integrationType.Gmail + }) + if (integrationSetting != null) { + await client.remove(integrationSetting) + ctx.info('Deleted integration setting', { userId, workspace }) + } +} diff --git a/services/gmail/pod-gmail/src/main.ts b/services/gmail/pod-gmail/src/main.ts index aae9f66128..7da8a39086 100644 --- a/services/gmail/pod-gmail/src/main.ts +++ b/services/gmail/pod-gmail/src/main.ts @@ -18,17 +18,17 @@ import { SplitLogger } from '@hcengineering/analytics-service' import { MeasureMetricsContext, newMetrics } from '@hcengineering/core' import { setMetadata } from '@hcengineering/platform' -import serverClient from '@hcengineering/server-client' +import serverClient, { getAccountClient } from '@hcengineering/server-client' +import { isWorkspaceLoginInfo } from '@hcengineering/account-client' import { initStatisticsContext, type StorageConfiguration } from '@hcengineering/server-core' import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage' -import serverToken from '@hcengineering/server-token' +import serverToken, { decodeToken } from '@hcengineering/server-token' import { type IncomingHttpHeaders } from 'http' import { join } from 'path' import { decode64 } from './base64' import config from './config' import { GmailController } from './gmailController' import { createServer, listen } from './server' -import { closeDB, getDB } from './storage' import { type Endpoint, type State } from './types' const extractToken = (header: IncomingHttpHeaders): any => { @@ -61,8 +61,7 @@ export const main = async (): Promise => { const storageConfig: StorageConfiguration = storageConfigFromEnv() const storageAdapter = buildStorageFromConfig(storageConfig) - const db = await getDB() - const gmailController = GmailController.create(ctx, db, storageAdapter) + const gmailController = GmailController.create(ctx, storageAdapter) await gmailController.startAll() const endpoints: Endpoint[] = [ { @@ -70,22 +69,30 @@ export const main = async (): Promise => { type: 'get', handler: async (req, res) => { try { - // TODO: FIXME - throw new Error('Not implemented') - // const token = extractToken(req.headers) + ctx.info('Signin request received') + const token = extractToken(req.headers) - // if (token === undefined) { - // res.status(401).send() - // return - // } - // const redirectURL = req.query.redirectURL as string + if (token === undefined) { + res.status(401).send() + return + } + const redirectURL = req.query.redirectURL as string - // const { workspace } = decodeToken(token) - // const gmail = await gmailController.getGmailClient(email, workspace, token) - // const url = gmail.getAutUrl(redirectURL) - // res.send(url) + const accountClient = getAccountClient(token) + const wsLoginInfo = await accountClient.getLoginInfoByToken() + if (!isWorkspaceLoginInfo(wsLoginInfo)) { + res.status(400).send({ err: "Couldn't find workspace with the provided token" }) + return + } + + const authProvider = gmailController.getAuthProvider() + const url = authProvider.getAuthUrl(redirectURL, { + workspace: wsLoginInfo.workspace, + userId: wsLoginInfo.account + }) + res.send(url) } catch (err) { - console.log('signin error', (err as any).message) + ctx.error('signin error', { message: (err as any).message }) res.status(500).send() } } @@ -94,31 +101,36 @@ export const main = async (): Promise => { endpoint: '/signin/code', type: 'get', handler: async (req, res) => { - const code = req.query.code as string - const state = JSON.parse(decode64(req.query.state as string)) as unknown as State - const gmail = await gmailController.createClient(state) - await gmail.authorize(code) - res.redirect(state.redirectURL) + try { + ctx.info('Signin code request received') + const code = req.query.code as string + const state = JSON.parse(decode64(req.query.state as string)) as unknown as State + await gmailController.createClient(state, code) + res.redirect(state.redirectURL) + } catch (err) { + ctx.error('Failed to process signin code', { message: (err as any).message }) + res.status(500).send() + } } }, { endpoint: '/signout', type: 'get', handler: async (req, res) => { + ctx.info('Signout request received') try { - // TODO: FIXME - throw new Error('Not implemented') - // const token = extractToken(req.headers) + const token = extractToken(req.headers) - // if (token === undefined) { - // res.status(401).send() - // return - // } + if (token === undefined) { + res.status(401).send() + return + } - // const { email, workspace } = decodeToken(token) - // await gmailController.signout(workspace.name, email) + const { account, workspace } = decodeToken(token) + + await gmailController.signout(workspace, account) } catch (err) { - console.log('signout error', JSON.stringify(err)) + ctx.error('signout error', { message: JSON.stringify(err) }) } res.send() @@ -128,14 +140,19 @@ export const main = async (): Promise => { endpoint: '/push', type: 'post', handler: async (req, res) => { - const data = req.body?.message?.data - if (data === undefined) { - res.status(400).send({ err: "'data' is missing" }) - return - } - gmailController.push(data) + try { + const data = req.body?.message?.data + if (data === undefined) { + res.status(400).send({ err: "'data' is missing" }) + return + } + gmailController.push(data) - res.send() + res.send() + } catch (err) { + ctx.error('Push request failed', { message: JSON.stringify(err) }) + res.status(500).send() + } } } ] @@ -145,7 +162,6 @@ export const main = async (): Promise => { const asyncClose = async (): Promise => { await gmailController.close() await storageAdapter.close() - await closeDB() } const shutdown = (): void => { diff --git a/services/gmail/pod-gmail/src/message/attachments.ts b/services/gmail/pod-gmail/src/message/attachments.ts new file mode 100644 index 0000000000..6cf3c325f4 --- /dev/null +++ b/services/gmail/pod-gmail/src/message/attachments.ts @@ -0,0 +1,165 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import attachment, { Attachment } from '@hcengineering/attachment' +import { AttachedData, Blob, MeasureContext, Ref, TxOperations, WorkspaceUuid } from '@hcengineering/core' +import { StorageAdapter } from '@hcengineering/server-core' +import { gmail_v1 } from 'googleapis' +import { v4 as uuid } from 'uuid' +import { encode64 } from '../base64' +import type { AttachedFile } from '../types' +import { addFooter } from '../utils' + +export class AttachmentHandler { + constructor ( + private readonly ctx: MeasureContext, + private readonly workspaceId: WorkspaceUuid, + private readonly storageAdapter: StorageAdapter, + private readonly gmail: gmail_v1.Resource$Users, + private readonly client: TxOperations + ) {} + + async addAttachement (file: AttachedFile, message: any, currentAttachemtns: Attachment[]): Promise { + try { + if (currentAttachemtns.findIndex((p) => p.name === file.name && p.lastModified === file.lastModified) !== -1) { + return + } + const id = uuid() + const data: AttachedData = { + name: file.name, + file: id as Ref, + type: file.type ?? 'undefined', + size: file.size ?? Buffer.from(file.file, 'base64').length, + lastModified: file.lastModified + } + await this.storageAdapter.put(this.ctx, this.workspaceId as any, id, file.file, data.type, data.size) // TODO: FIXME + await this.client.addCollection( + attachment.class.Attachment, + message.space, + message._id, + message._class, + 'attachments', + data + ) + } catch (err: any) { + this.ctx.error('Add attachment error', { error: err.message }) + } + } + + async getPartFiles (part: gmail_v1.Schema$MessagePart | undefined, messageId: string): Promise { + if (part === undefined) return [] + if (part.filename != null && part.filename.length > 0) { + if (part.body?.attachmentId != null) { + const attachment = await this.getAttachmentById(part.body?.attachmentId, messageId) + if (attachment.data == null) return [] + return [ + { + file: attachment.data, + name: part.filename, + type: part.mimeType ?? undefined, + size: attachment.size ?? undefined, + lastModified: new Date().getTime() + } + ] + } + if (part.body?.data == null) return [] + return [ + { + file: part.body.data, + name: part.filename, + type: part.mimeType ?? undefined, + size: part.body.size ?? undefined, + lastModified: new Date().getTime() + } + ] + } + return await this.getPartsFiles(part.parts, messageId) + } + + private async getAttachmentById (attachmentId: string, messageId: string): Promise { + const res = await this.gmail.messages.attachments.get({ id: attachmentId, userId: 'me', messageId }) + return res.data + } + + private async getPartsFiles ( + parts: gmail_v1.Schema$MessagePart[] | undefined, + messageId: string + ): Promise { + const result: AttachedFile[] = [] + if (parts !== undefined) { + const filtredParts = parts.filter((part) => part.filename != null && part.filename.length > 0) + for (const part of filtredParts ?? []) { + const res = await this.getPartFiles(part, messageId) + if (res.length > 0) { + result.push(...res) + } + } + } + return result + } + + async makeAttachmentsBody (message: any, from: string): Promise { + const str = [ + 'Content-Type: multipart/mixed; boundary="mail"\n', + 'MIME-Version: 1.0\n', + `To: ${message.to} \n`, + `From: ${from} \n` + ] + + if (message.replyTo != null) { + str.push(`In-Reply-To: ${message.replyTo} \n`) + } + + if (message.copy != null && message.copy.length > 0) { + str.push(`Cc: ${message.copy.join(', ')} \n`) + } + + if (message.subject != null) { + str.push(`Subject: =?UTF-8?B?${encode64(message.subject)}?= \n`) + } + + str.push('\n\n') + str.push('--mail\n') + str.push('Content-Type: text/html; charset="UTF-8"\n') + str.push('MIME-Version: 1.0\n') + str.push('Content-Transfer-Encoding: 7bit\n\n') + str.push(addFooter(message.content)) + str.push('\n\n') + + const attachments = await this.client.findAll(attachment.class.Attachment, { attachedTo: message._id }) + + for (const attachment of attachments) { + const attachmentStrings = await this.makeAttachmentPart(attachment) + str.push(...attachmentStrings) + } + + str.push('--mail--') + + return encode64(str.join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + } + + private async makeAttachmentPart (attachment: Attachment): Promise { + const buffer = await this.storageAdapter.read(this.ctx, this.workspaceId as any, attachment.file) // TODO: FIXME + const data = Buffer.concat(buffer.map((b) => new Uint8Array(b))).toString('base64') + const res: string[] = [] + res.push('--mail\n') + res.push(`Content-Type: ${attachment.type}\n`) + res.push('MIME-Version: 1.0\n') + res.push('Content-Transfer-Encoding: base64\n') + res.push(`Content-Disposition: attachment; filename="${attachment.name}"\n\n`) + res.push(data) + res.push('\n\n') + return res + } +} diff --git a/services/gmail/pod-gmail/src/message/message.ts b/services/gmail/pod-gmail/src/message/message.ts new file mode 100644 index 0000000000..87b7e7187a --- /dev/null +++ b/services/gmail/pod-gmail/src/message/message.ts @@ -0,0 +1,201 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { + type MeasureContext, + PersonId, + Timestamp, + TxCUD, + TxFactory, + TxOperations, + TxProcessor, + TxCreateDoc, + TxUpdateDoc, + AttachedData, + Data +} from '@hcengineering/core' +import gmail, { type Message } from '@hcengineering/gmail' +import { type GaxiosResponse } from 'gaxios' +import { gmail_v1 } from 'googleapis' +import core from '@hcengineering/core' +import attachment, { Attachment } from '@hcengineering/attachment' +import sanitizeHtml from 'sanitize-html' + +import { type Channel } from '../types' +import { AttachmentHandler } from './attachments' +import { decode64 } from '../base64' +import { diffAttributes } from '../utils' + +const EMAIL_REGEX = + /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/ + +export class MessageManager { + constructor ( + private readonly ctx: MeasureContext, + private readonly client: TxOperations, + private readonly attachmentHandler: AttachmentHandler, + private readonly socialId: PersonId, + private readonly workspace: { getChannel: (email: string) => Channel | undefined } + ) {} + + private findChannels (res: AttachedData): Channel[] { + const recievers = res.copy != null ? [res.to, ...res.copy] : [res.to] + const values = res.incoming ? [res.from] : recievers + const result: Channel[] = [] + for (const value of values) { + const res = value.match(EMAIL_REGEX) + if (res !== null) { + const email = res[0] + const channel = this.workspace.getChannel(email) + if (channel !== undefined) result.push(channel) + } + } + return result + } + + async saveMessage (message: GaxiosResponse, me: string): Promise { + const res = convertMessage(message, me) + const channels = this.findChannels(res) + if (channels.length === 0) return + const attachments = await this.attachmentHandler.getPartFiles(message.data.payload, message.data.id ?? '') + const factory = new TxFactory(this.socialId) + for (const channel of channels) { + const current = await this.client.findOne(gmail.class.Message, { + messageId: res.messageId, + attachedTo: channel._id + }) + const tx = current != null ? this.updateTx(res, current, factory, channel) : this.createTx(res, factory, channel) + if (tx !== undefined) { + const resultMessage = + current != null + ? TxProcessor.updateDoc2Doc(current, tx as TxUpdateDoc) + : TxProcessor.createDoc2Doc(tx as TxCreateDoc) + await this.client.tx(tx) + if (attachments.length > 0) { + const currentAttachemtns: Attachment[] = + current !== undefined + ? await this.client.findAll(attachment.class.Attachment, { attachedTo: current._id }) + : [] + for (const attachment of attachments) { + await this.attachmentHandler.addAttachement(attachment, resultMessage, currentAttachemtns) + } + } + } + } + } + + private createTx ( + message: AttachedData & { modifiedOn: Timestamp }, + factory: TxFactory, + channel: Channel + ): TxCUD { + const tx = factory.createTxCollectionCUD( + channel._class, + channel._id, + channel.space, + 'items', + factory.createTxCreateDoc( + gmail.class.Message, + core.space.Workspace, + message as unknown as Data, + undefined, + message.modifiedOn + ), + message.modifiedOn + ) + return tx + } + + private updateTx ( + message: AttachedData & { modifiedOn: Timestamp }, + current: Message, + factory: TxFactory, + channel: Channel + ): TxCUD | undefined { + const operations = diffAttributes(current, message) + if (Object.keys(operations).length === 0) return undefined + const tx = factory.createTxCollectionCUD( + channel._class, + channel._id, + channel.space, + 'items', + factory.createTxUpdateDoc(current._class, current.space, current._id, operations) + ) + return tx + } +} + +function getHeaderValue (payload: gmail_v1.Schema$MessagePart | undefined, name: string): string | undefined { + if (payload === undefined) return undefined + const headers = payload.headers + + return headers?.find((header) => header.name?.toLowerCase() === name.toLowerCase())?.value ?? undefined +} + +function getPartsMessage (parts: gmail_v1.Schema$MessagePart[] | undefined, mime: string): string { + let result = '' + if (parts !== undefined) { + const htmlPart = parts.find((part) => part.mimeType === mime) + const filtredParts = htmlPart !== undefined ? parts.filter((part) => part.mimeType === mime) : parts + for (const part of filtredParts ?? []) { + result += getPartMessage(part, mime) + } + } + return result +} + +const sanitizeOptions: sanitizeHtml.IOptions = { + allowedTags: [], + allowedAttributes: {} +} + +export function sanitizeText (input: string): string { + if (input == null) return '' + return sanitizeHtml(input, sanitizeOptions) +} + +function getPartMessage (part: gmail_v1.Schema$MessagePart | undefined, mime: string): string { + if (part === undefined) return '' + if (part.body?.data != null) { + return decode64(part.body.data) + } + return getPartsMessage(part.parts, mime) +} + +function convertMessage ( + message: GaxiosResponse, + me: string +): AttachedData & { modifiedOn: Timestamp } { + const date = message.data.internalDate != null ? new Date(Number.parseInt(message.data.internalDate)) : new Date() + const from = getHeaderValue(message.data.payload, 'From') ?? '' + const to = getHeaderValue(message.data.payload, 'To') ?? '' + const copy = + getHeaderValue(message.data.payload, 'Cc') + ?.split(',') + .map((p) => p.trim()) ?? undefined + const incoming = !from.includes(me) + return { + modifiedOn: date.getTime(), + messageId: getHeaderValue(message.data.payload, 'Message-ID') ?? '', + replyTo: getHeaderValue(message.data.payload, 'In-Reply-To'), + copy, + content: sanitizeHtml(getPartMessage(message.data.payload, 'text/html')), + textContent: sanitizeText(getPartMessage(message.data.payload, 'text/plain')), + from, + to, + incoming, + subject: getHeaderValue(message.data.payload, 'Subject') ?? '', + sendOn: date.getTime() + } +} diff --git a/services/gmail/pod-gmail/src/message/sync.ts b/services/gmail/pod-gmail/src/message/sync.ts new file mode 100644 index 0000000000..d3a26afae4 --- /dev/null +++ b/services/gmail/pod-gmail/src/message/sync.ts @@ -0,0 +1,246 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { type GaxiosResponse } from 'gaxios' +import { gmail_v1 } from 'googleapis' + +import { type MeasureContext, PersonId } from '@hcengineering/core' +import { type KeyValueClient } from '@hcengineering/kvs-client' + +import { RateLimiter } from '../rateLimiter' +import { MessageManager } from './message' + +interface History { + historyId: string + userId: string + workspace: string +} + +export class SyncMutex { + private readonly locks = new Map>() + + async lock (key: string): Promise<() => void> { + // Wait for any existing lock to be released + const currentLock = this.locks.get(key) + if (currentLock != null) { + await currentLock + } + + // Create a new lock + let releaseFn!: () => void + const newLock = new Promise((resolve) => { + releaseFn = resolve + }) + + // Store the lock + this.locks.set(key, newLock) + + // Return the release function + return () => { + if (this.locks.get(key) === newLock) { + this.locks.delete(key) + } + releaseFn() + } + } +} + +export class SyncManager { + private readonly syncMutex = new SyncMutex() + + constructor ( + private readonly ctx: MeasureContext, + private readonly messageManager: MessageManager, + private readonly gmail: gmail_v1.Resource$Users, + private readonly workspace: string, + private readonly keyValueClient: KeyValueClient, + private readonly rateLimiter: RateLimiter + ) {} + + private async getHistory (userId: PersonId): Promise { + const historyKey = this.getHistoryKey(userId) + return await this.keyValueClient.getValue(historyKey) + } + + private async clearHistory (userId: PersonId): Promise { + const historyKey = this.getHistoryKey(userId) + await this.keyValueClient.deleteKey(historyKey) + } + + private async setHistoryId (userId: PersonId, historyId: string): Promise { + const historyKey = this.getHistoryKey(userId) + const history: History = { + historyId, + userId, + workspace: this.workspace + } + await this.keyValueClient.setValue(historyKey, history) + } + + private async partSync (userId: PersonId, userEmail: string | undefined, historyId: string): Promise { + if (userEmail === undefined) { + throw new Error('Cannot sync without user email') + } + let pageToken: string | undefined + let histories: GaxiosResponse + while (true) { + try { + await this.rateLimiter.take(2) + histories = await this.gmail.history.list({ + userId: 'me', + historyTypes: ['messageAdded'], + startHistoryId: historyId, + pageToken + }) + } catch (err: any) { + this.ctx.error('Part sync get history error', { workspaceUuid: this.workspace, userId, error: err.message }) + await this.clearHistory(userId) + void this.sync(userId) + return + } + const nextPageToken = histories.data.nextPageToken + const array = histories.data.history ?? [] + this.ctx.info('Messages to migrate', { count: array.length }) + try { + for (const history of array) { + for (const message of history.messagesAdded ?? []) { + if (message.message?.id == null || (message.message.labelIds?.includes('DRAFT') ?? false)) { + continue + } + try { + const res = await this.getMessage(message.message.id) + await this.messageManager.saveMessage(res, userEmail) + } catch (err) { + this.ctx.error('Part sync message error', { + workspaceUuid: this.workspace, + userId, + messageId: message.message.id, + err + }) + } + } + if (history.id != null) { + await this.setHistoryId(userId, history.id) + } + } + if (nextPageToken == null) { + return + } else { + pageToken = nextPageToken + } + } catch (err) { + this.ctx.error('Part sync error', { workspaceUuid: this.workspace, userId, historyId, err }) + return + } + } + } + + async fullSync (userId: PersonId, userEmail?: string, q?: string): Promise { + this.ctx.info('Start full sync', { workspaceUuid: this.workspace, userId, userEmail }) + if (userEmail === undefined) { + throw new Error('Cannot sync without user email') + } + const pageToken: string | undefined = undefined + const query: gmail_v1.Params$Resource$Users$Messages$List = { + userId: 'me', + pageToken + } + if (q !== undefined) { + query.q = q + } + let currentHistoryId: string | undefined + try { + const messagesIds: string[] = [] + while (true) { + await this.rateLimiter.take(5) + const messages = await this.gmail.messages.list(query) + if (query.pageToken == null) { + this.ctx.info('Total messages', { + workspace: this.workspace, + userId, + resultSizeEstimate: messages.data.resultSizeEstimate + }) + } + const ids = messages.data.messages?.map((p) => p.id) ?? [] + for (let index = 0; index < ids.length; index++) { + const id = ids[index] + if (id == null) continue + messagesIds.push(id) + } + if (messages.data.nextPageToken == null) { + this.ctx.info('Break', { totalNewMessages: messagesIds.length }) + break + } + query.pageToken = messages.data.nextPageToken + } + for (let index = messagesIds.length - 1; index >= 0; index--) { + const id = messagesIds[index] + try { + const message = await this.getMessage(id) + const historyId = message.data.historyId + await this.messageManager.saveMessage(message, userEmail) + if (historyId != null && q === undefined) { + if (currentHistoryId == null || Number(currentHistoryId) < Number(historyId)) { + await this.setHistoryId(userId, historyId) + currentHistoryId = historyId + } + } + if (index % 500 === 0) { + this.ctx.info('Remaining messages to sync', { workspace: this.workspace, userId, count: index }) + } + } catch (err: any) { + this.ctx.error('Full sync message error', { workspace: this.workspace, userId, err }) + } + } + this.ctx.info('Full sync finished', { workspaceUuid: this.workspace, userId, userEmail }) + } catch (err) { + this.ctx.error('Full sync error', { workspace: this.workspace, userId, err }) + } + } + + private async getMessage (id: string): Promise> { + await this.rateLimiter.take(5) + return await this.gmail.messages.get({ + id, + userId: 'me', + format: 'FULL' + }) + } + + async sync (userId: PersonId, userEmail?: string): Promise { + const mutexKey = `${this.workspace}:${userId}` + const releaseLock = await this.syncMutex.lock(mutexKey) + + try { + this.ctx.info('Sync history', { workspaceUuid: this.workspace, userId, userEmail }) + + const history = await this.getHistory(userId) + if (history?.historyId != null && history?.historyId !== '') { + this.ctx.info('Start part sync', { workspaceUuid: this.workspace, userId, historyId: history.historyId }) + await this.partSync(userId, userEmail, history.historyId) + } else { + this.ctx.info('Start full sync', { workspaceUuid: this.workspace, userId }) + await this.fullSync(userId, userEmail) + } + } catch (err) { + this.ctx.error('Sync error', { workspace: this.workspace, userId, err }) + } finally { + releaseLock() + } + } + + private getHistoryKey (userId: PersonId): string { + return `history:${this.workspace}:${userId}` + } +} diff --git a/services/gmail/pod-gmail/src/storage.ts b/services/gmail/pod-gmail/src/storage.ts deleted file mode 100644 index 6dc4e8d6de..0000000000 --- a/services/gmail/pod-gmail/src/storage.ts +++ /dev/null @@ -1,36 +0,0 @@ -// -// Copyright © 2020, 2021 Anticrm Platform Contributors. -// Copyright © 2021 Hardcore Engineering Inc. -// -// Licensed under the Eclipse Public License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. You may -// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import { MongoClientReference, getMongoClient } from '@hcengineering/mongo' -import { MongoClient } from 'mongodb' - -import config from './config' - -const clientRef: MongoClientReference = getMongoClient(config.MongoURI) -let client: MongoClient | undefined -export const getDB = (() => { - return async () => { - if (client === undefined) { - client = await clientRef.getClient() - } - - return client.db(config.MongoDB) - } -})() - -export const closeDB: () => Promise = async () => { - clientRef.close() -} diff --git a/services/gmail/pod-gmail/src/tokens.ts b/services/gmail/pod-gmail/src/tokens.ts new file mode 100644 index 0000000000..0120c91ae5 --- /dev/null +++ b/services/gmail/pod-gmail/src/tokens.ts @@ -0,0 +1,86 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { MeasureContext, PersonId, WorkspaceUuid } from '@hcengineering/core' +import type { AccountClient } from '@hcengineering/account-client' +import { getAccountClient } from '@hcengineering/server-client' +import { GMAIL_INTEGRATION, SecretType, Token } from './types' + +export class TokenStorage { + private readonly accountClient: AccountClient + + constructor ( + private readonly ctx: MeasureContext, + private readonly workspace: WorkspaceUuid, + platformToken: string + ) { + this.accountClient = getAccountClient(platformToken) + } + + async getToken (socialId: PersonId): Promise { + const secret = await this.accountClient.getIntegrationSecret({ + key: SecretType.TOKEN, + kind: GMAIL_INTEGRATION, + socialId, + workspaceUuid: this.workspace + }) + return secret?.secret !== undefined ? JSON.parse(secret.secret) : null + } + + async saveToken (socialId: PersonId, token: Token): Promise { + const existingToken = await this.getToken(socialId) + + if (existingToken !== null) { + const updatedToken = { + ...existingToken, + ...token + } + await this.accountClient.updateIntegrationSecret({ + key: SecretType.TOKEN, + kind: GMAIL_INTEGRATION, + socialId, + secret: JSON.stringify(updatedToken), + workspaceUuid: this.workspace + }) + this.ctx.info('Updated integration secret', { socialId, workspaceUuid: this.workspace }) + } else { + await this.accountClient.addIntegrationSecret({ + key: SecretType.TOKEN, + kind: GMAIL_INTEGRATION, + socialId, + secret: JSON.stringify(token), + workspaceUuid: this.workspace + }) + this.ctx.info('Created integration secret', { socialId, workspaceUuid: this.workspace }) + } + } + + async deleteToken (socialId: PersonId): Promise { + await this.accountClient.deleteIntegrationSecret({ + key: SecretType.TOKEN, + kind: GMAIL_INTEGRATION, + socialId, + workspaceUuid: this.workspace + }) + } +} + +export async function getWorkspaceTokens (accountClient: AccountClient, workspace: WorkspaceUuid): Promise { + const secrets = await accountClient.listIntegrationsSecrets({ + kind: GMAIL_INTEGRATION, + workspaceUuid: workspace + }) + return secrets.map((secret: { secret: string }) => JSON.parse(secret.secret)) +} diff --git a/services/gmail/pod-gmail/src/types.ts b/services/gmail/pod-gmail/src/types.ts index 60214cb334..2e0b3b3184 100644 --- a/services/gmail/pod-gmail/src/types.ts +++ b/services/gmail/pod-gmail/src/types.ts @@ -14,7 +14,7 @@ // limitations under the License. // -import type { PersonId, Doc, WorkspaceUuid } from '@hcengineering/core' +import type { AccountUuid, Doc, SocialId, WorkspaceUuid } from '@hcengineering/core' import type { NextFunction, Request, Response } from 'express' import type { Credentials } from 'google-auth-library' import type { Channel as PlatformChannel } from '@hcengineering/contact' @@ -26,7 +26,9 @@ export type History = User & { } export interface User { - userId: PersonId + userId: AccountUuid + email?: string + socialId: SocialId workspace: WorkspaceUuid token: string } @@ -68,3 +70,8 @@ export interface ProjectCredentialsData { client_secret: string redirect_uris: string[] } +export const GMAIL_INTEGRATION = 'gmail' + +export enum SecretType { + TOKEN = 'token' +} diff --git a/services/gmail/pod-gmail/src/utils.ts b/services/gmail/pod-gmail/src/utils.ts index 193fd39860..d5de1d1d1b 100644 --- a/services/gmail/pod-gmail/src/utils.ts +++ b/services/gmail/pod-gmail/src/utils.ts @@ -14,9 +14,12 @@ // limitations under the License. // -import { type Data, type Doc, type DocumentUpdate } from '@hcengineering/core' +import { type Data, type Doc, type DocumentUpdate, systemAccountUuid } from '@hcengineering/core' +import { generateToken } from '@hcengineering/server-token' import { deepEqual } from 'fast-equals' +import { type KeyValueClient, getClient as getKeyValueClient } from '@hcengineering/kvs-client' import { type Token, type User } from './types' +import config from './config' export class DeferredPromise { public readonly promise: Promise @@ -63,3 +66,27 @@ function toUndef (value: any): any { export function isToken (user: User | Token): user is Token { return (user as Token).access_token !== undefined } + +export function addFooter (message: string): string { + if (config.FooterMessage === undefined || config.FooterMessage.trim() === '') return message + return message + config.FooterMessage.trim() +} + +export function serviceToken (): string { + return generateToken(systemAccountUuid, undefined, { service: 'gmail' }) +} + +export async function wait (sec: number): Promise { + await new Promise((resolve) => { + setTimeout(() => { + resolve() + }, sec * 1000) + }) +} + +let keyValueClient: KeyValueClient | undefined +export function getKvsClient (token: string): KeyValueClient { + if (keyValueClient !== undefined) return keyValueClient + keyValueClient = getKeyValueClient('gmail', config.KvsUrl, token) + return keyValueClient +} diff --git a/services/gmail/pod-gmail/src/workspaceClient.ts b/services/gmail/pod-gmail/src/workspaceClient.ts index f26c537d77..08d9fc546b 100644 --- a/services/gmail/pod-gmail/src/workspaceClient.ts +++ b/services/gmail/pod-gmail/src/workspaceClient.ts @@ -14,10 +14,9 @@ // limitations under the License. // -import contact, { type Employee, type Channel as PlatformChannel } from '@hcengineering/contact' +import contact, { type Channel as PlatformChannel, type Person, Employee } from '@hcengineering/contact' import core, { type WorkspaceUuid, - type PersonId, type Client, type Doc, MeasureContext, @@ -27,15 +26,19 @@ import core, { type TxCreateDoc, TxProcessor, type TxRemoveDoc, - type TxUpdateDoc + type TxUpdateDoc, + PersonId, + AccountUuid, + TxOperations } from '@hcengineering/core' import gmailP, { type NewMessage } from '@hcengineering/gmail' import type { StorageAdapter } from '@hcengineering/server-core' import { generateToken } from '@hcengineering/server-token' -import { type Db } from 'mongodb' import { getClient } from './client' import { GmailClient } from './gmail' import { type Channel, type ProjectCredentials, type User } from './types' +import { getAccountSocialIds } from './accounts' +import { cleanIntegrations } from './integrations' export class WorkspaceClient { private messageSubscribed: boolean = false @@ -49,7 +52,6 @@ export class WorkspaceClient { private constructor ( private readonly ctx: MeasureContext, private readonly credentials: ProjectCredentials, - private readonly mongo: Db, private readonly storageAdapter: StorageAdapter, private readonly workspace: WorkspaceUuid ) {} @@ -57,29 +59,30 @@ export class WorkspaceClient { static async create ( ctx: MeasureContext, credentials: ProjectCredentials, - mongo: Db, storageAdapter: StorageAdapter, workspace: WorkspaceUuid ): Promise { - const instance = new WorkspaceClient(ctx, credentials, mongo, storageAdapter, workspace) + const instance = new WorkspaceClient(ctx, credentials, storageAdapter, workspace) await instance.initClient(workspace) return instance } - async createGmailClient (user: User): Promise { - const current = this.getGmailClient(user.userId) + async createGmailClient (user: User, authCode?: string): Promise { + const current = user.socialId?._id !== undefined ? this.getGmailClient(user.socialId?._id) : undefined if (current !== undefined) return current + this.ctx.info('Creating new gmail client', { workspaceUuid: this.workspace, userId: user.userId }) + this.ctx.info('Creating new gmail user', { user }) const newClient = await GmailClient.create( this.ctx, this.credentials, user, - this.mongo, this.client, this, this.workspace, - this.storageAdapter + this.storageAdapter, + authCode ) - this.clients.set(user.userId, newClient) + this.clients.set(user.socialId._id, newClient) return newClient } @@ -91,32 +94,34 @@ export class WorkspaceClient { await this.client?.close() } - async getUserId (email: string): Promise { - // TODO: FIXME - throw new Error('Not implemented') - // const user = this.client.getModel().getAccountByEmail(email) - // if (user === undefined) { - // throw new Error('User not found') - // } - // return user._id - } - - async signout (email: string, byError: boolean = false): Promise { - const userId = await this.getUserId(email) - const client = this.clients.get(userId) - if (client !== undefined) { - await client.signout(byError) + async signoutByAccountId (userId: AccountUuid, byError: boolean = false): Promise { + const socialIds = await getAccountSocialIds(userId) + this.ctx.info('socialIds', { socialIds }) + this.ctx.info('clients', { clients: this.clients }) + let deleted = false + for (const socialId of socialIds) { + const client = this.clients.get(socialId._id) + if (client !== undefined) { + await client.signout(byError) + this.clients.delete(socialId._id) + deleted = true + } } - this.clients.delete(userId) + if (!deleted && socialIds.length > 0) { + this.ctx.info('Clean up integrations without clients') + const tx = new TxOperations(this.client, socialIds[0]._id) + await cleanIntegrations(this.ctx, tx, userId, this.workspace) + } + return this.clients.size } - async signoutByUserId (userId: PersonId, byError: boolean = false): Promise { - const client = this.clients.get(userId) + async signoutBySocialId (socialId: PersonId, byError: boolean = false): Promise { + const client = this.clients.get(socialId) if (client !== undefined) { await client.signout(byError) + this.clients.delete(socialId) } - this.clients.delete(userId) return this.clients.size } @@ -126,7 +131,7 @@ export class WorkspaceClient { private async initClient (workspace: WorkspaceUuid): Promise { const token = generateToken(systemAccountUuid, workspace, { service: 'gmail' }) - console.log('token', token, workspace) + this.ctx.info('Init client', { workspaceUuid: workspace }) const client = await getClient(token) client.notify = (...tx: Tx[]) => { void this.txHandler(...tx) @@ -161,7 +166,7 @@ export class WorkspaceClient { const newMessages = await this.client.findAll(gmailP.class.NewMessage, { status: 'new' }) - console.log('get new messages, recieved', this.workspace, newMessages.length) + this.ctx.info('get new messages', { workspaceUuid: this.workspace, count: newMessages.length }) await this.subscribeMessages() for (const message of newMessages) { const from = message.from ?? message.createdBy ?? message.modifiedBy @@ -169,7 +174,11 @@ export class WorkspaceClient { if (client !== undefined) { await client.createMessage(message) } else { - console.log('client not found, skip message', this.workspace, from, message._id) + this.ctx.error('client not found, skip message', { + workspaceUuid: this.workspace, + from, + messageId: message._id + }) } } } @@ -326,41 +335,36 @@ export class WorkspaceClient { // #region Users async checkUsers (): Promise { - // TODO: FIXME - throw new Error('Not implemented') - // const removedEmployees = await this.client.findAll(contact.mixin.Employee, { - // active: false - // }) - // const accounts = await this.client.findAll(contact.class.PersonAccount, { - // person: { $in: removedEmployees.map((p) => p._id) } - // }) - // for (const acc of accounts) { - // await this.deactivateUser(acc) - // } - // this.txHandlers.push(async (...txes: Tx[]) => { - // for (const tx of txes) { - // await this.txEmployeeHandler(tx) - // } - // }) - // console.log('deactivate users', this.workspace, accounts.length) + const removedEmployees = await this.client.findAll(contact.mixin.Employee, { + active: false + }) + + for (const person of removedEmployees) { + await this.deactivateUser(person) + } + this.txHandlers.push(async (...txes: Tx[]) => { + for (const tx of txes) { + await this.txEmployeeHandler(tx) + } + }) + this.ctx.info('deactivate users', { workspaceUuid: this.workspace, count: removedEmployees.length }) } - // private async deactivateUser (acc: PersonAccount): Promise { - // await this.signout(acc.email, true) - // } + private async deactivateUser (acc: Person): Promise { + if (acc.personUuid === undefined) return + await this.signoutByAccountId(acc.personUuid as AccountUuid, true) + } private async txEmployeeHandler (tx: Tx): Promise { - // TODO: FIXME - throw new Error('Not implemented') - // if (tx._class !== core.class.TxUpdateDoc) return - // const ctx = tx as TxUpdateDoc - // if (!this.client.getHierarchy().isDerived(ctx.objectClass, contact.mixin.Employee)) return - // if (ctx.operations.active === false) { - // const acc = await this.client.findOne(contact.class.PersonAccount, { person: ctx.objectId }) - // if (acc !== undefined) { - // await this.deactivateUser(acc) - // } - // } + if (tx._class !== core.class.TxUpdateDoc) return + const ctx = tx as TxUpdateDoc + if (!this.client.getHierarchy().isDerived(ctx.objectClass, contact.mixin.Employee)) return + if (ctx.operations.active === false) { + const acc = await this.client.findOne(contact.class.Person, { _id: ctx.objectId }) + if (acc !== undefined) { + await this.deactivateUser(acc) + } + } } // #endregion