mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-13 21:27:46 +02:00
UBERF-9764: Adjust gmail for new accounts (#8681)
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Generated
+186
-6
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<WorkspaceInfoWithStatus | undefined>
|
||||
}
|
||||
|
||||
const GMAIL_INTEGRATION = 'gmail'
|
||||
const TOKEN_TYPE = 'token'
|
||||
|
||||
export async function performGmailAccountMigrations (db: Db, region: string | null, kvsUrl: string): Promise<void> {
|
||||
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<void> {
|
||||
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<Token>('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<void> {
|
||||
try {
|
||||
console.log('Start Gmail history migrations')
|
||||
const accountClient = getAccountClient(token)
|
||||
const history = db.collection<History>('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<HistoryV2>(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}`
|
||||
}
|
||||
+17
-1
@@ -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>', 'DB name', 'gmail-service')
|
||||
.option('--region <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)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: './tsconfig.json'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!/lib/**
|
||||
!CHANGELOG.md
|
||||
/lib/**/__tests__/
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
|
||||
"rigPackageName": "@hcengineering/platform-rig"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
|
||||
roots: ["./src"],
|
||||
coverageReporters: ["text-summary", "html"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Set up fetch mock
|
||||
require('jest-fetch-mock').enableMocks()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<T>(key: string, value: T): Promise<void> {
|
||||
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<T>(key: string): Promise<T | null> {
|
||||
const url = this.buildUrl(key)
|
||||
return await this.sendRequest<T>(url, {
|
||||
method: 'GET',
|
||||
returnNullOn404: true
|
||||
})
|
||||
}
|
||||
|
||||
async deleteKey (key: string): Promise<void> {
|
||||
const url = this.buildUrl(key)
|
||||
await this.sendRequest(url, {
|
||||
method: 'DELETE',
|
||||
acceptNotFound: true,
|
||||
errorMessage: 'Failed to delete key'
|
||||
})
|
||||
}
|
||||
|
||||
async listKeys<T>(prefix?: string): Promise<Record<string, T> | null> {
|
||||
let url = this.buildUrl()
|
||||
if (prefix !== undefined) {
|
||||
url += `?prefix=${encodeURIComponent(prefix)}`
|
||||
}
|
||||
return await this.sendRequest<Record<string, T>>(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<T>(
|
||||
url: string,
|
||||
options: {
|
||||
method: string
|
||||
body?: string
|
||||
contentType?: string
|
||||
returnNullOn404?: boolean
|
||||
acceptNotFound?: boolean
|
||||
errorMessage?: string
|
||||
}
|
||||
): Promise<T | null> {
|
||||
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<Response> {
|
||||
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<void>((resolve) => setTimeout(resolve, intervalMs))
|
||||
if (intervalMs < 1000) {
|
||||
intervalMs += 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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: <T>(key: string, value: T) => Promise<void>
|
||||
|
||||
/**
|
||||
* 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: <T>(key: string) => Promise<T | null>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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: <T>(prefix?: string) => Promise<Record<string, T> | null>
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">zásadami Google API Services User Data Policy</a>, 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"
|
||||
}
|
||||
}
|
||||
@@ -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 <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API Services User Data Policy</a>, 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"
|
||||
}
|
||||
}
|
||||
@@ -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 <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API Services User Data Policy</a>, including the Limited Use requirements.",
|
||||
"EmailNotificationsDescription": "Receive personal notifications on email."
|
||||
"EmailNotificationsDescription": "Receive personal notifications on email.",
|
||||
"FailedToConnect": "Failed to connect to Gmail"
|
||||
}
|
||||
}
|
||||
@@ -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 <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API Services User Data Policy</a>, 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"
|
||||
}
|
||||
}
|
||||
@@ -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 <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">règles d'utilisation des données utilisateur des services API Google</a>, 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"
|
||||
}
|
||||
}
|
||||
@@ -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 <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Politica sui dati degli utenti dei servizi API di Google</a>, inclusi i requisiti di utilizzo limitato.",
|
||||
"EmailNotificationsDescription": "Ricevi notifiche personali sulle email."
|
||||
"EmailNotificationsDescription": "Ricevi notifiche personali sulle email.",
|
||||
"FailedToConnect": "Impossibile connettere l'account Gmail"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API Services User Data Policy</a>, 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"
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@
|
||||
"ConfigLabel": "Электронная почта",
|
||||
"ConfigDescription": "Расширение по работе с Gmail электронной почтой",
|
||||
"GooglePrivacy": "Использование и передача информации, полученной Huly от Google API, будет соответствовать <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Политике использования данных пользователей Google API</a>, включая требования ограниченного использования.",
|
||||
"EmailNotificationsDescription": "Получайте персональные уведомления на электронную почту."
|
||||
"EmailNotificationsDescription": "Получайте персональные уведомления на электронную почту.",
|
||||
"FailedToConnect": "Не удалось подключиться к Gmail"
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@
|
||||
"ConfigLabel": "电子邮件",
|
||||
"ConfigDescription": "Gmail 邮件集成扩展",
|
||||
"GooglePrivacy": "Huly 从 Google API 接收的信息的使用和传输将遵守 <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API 服务用户数据政策</a>,包括有限使用要求。",
|
||||
"EmailNotificationsDescription": "透過電子郵件接收個人通知。"
|
||||
"EmailNotificationsDescription": "透過電子郵件接收個人通知。",
|
||||
"FailedToConnect": "無法連接 Gmail 帳戶。請檢查您的憑證並重試。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,12 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { Channel, Contact } from '@hcengineering/contact'
|
||||
import { employeeByIdStore } from '@hcengineering/contact-resources'
|
||||
import { Ref, SortingOrder } from '@hcengineering/core'
|
||||
import { Message, SharedMessage } from '@hcengineering/gmail'
|
||||
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
|
||||
import { createQuery, getClient } from '@hcengineering/presentation'
|
||||
import plugin, { Button, Icon, IconShare, Label, Scroller } from '@hcengineering/ui'
|
||||
|
||||
import { Integration } from '@hcengineering/setting'
|
||||
import gmail from '../plugin'
|
||||
import { convertMessages } from '../utils'
|
||||
import Messages from './Messages.svelte'
|
||||
@@ -33,7 +31,6 @@
|
||||
export let channel: Channel
|
||||
export let newMessage: boolean
|
||||
export let enabled: boolean
|
||||
export let allIntegrations: Integration[]
|
||||
|
||||
let plainMessages: Message[] = []
|
||||
let newMessages: Message[] = []
|
||||
@@ -76,28 +73,19 @@
|
||||
const client = getClient()
|
||||
|
||||
async function share (): Promise<void> {
|
||||
// TODO: FIXME
|
||||
throw new Error('Not implemented')
|
||||
// const selectedMessages = messages.filter((m) => selected.has(m._id as string as Ref<SharedMessage>))
|
||||
// await client.addCollection(
|
||||
// gmail.class.SharedMessages,
|
||||
// object.space,
|
||||
// object._id,
|
||||
// object._class,
|
||||
// 'gmailSharedMessages',
|
||||
// {
|
||||
// messages: convertMessages(
|
||||
// object,
|
||||
// channel,
|
||||
// selectedMessages,
|
||||
// allIntegrations,
|
||||
// $personAccountByIdStore,
|
||||
// $employeeByIdStore
|
||||
// )
|
||||
// }
|
||||
// )
|
||||
// await inboxClient.readDoc(channel._id)
|
||||
// clear()
|
||||
const selectedMessages = messages.filter((m) => selected.has(m._id as string as Ref<SharedMessage>))
|
||||
await client.addCollection(
|
||||
gmail.class.SharedMessages,
|
||||
object.space,
|
||||
object._id,
|
||||
object._class,
|
||||
'gmailSharedMessages',
|
||||
{
|
||||
messages: convertMessages(object, channel, selectedMessages)
|
||||
}
|
||||
)
|
||||
await inboxClient.readDoc(channel._id)
|
||||
clear()
|
||||
}
|
||||
|
||||
function clear (): void {
|
||||
@@ -140,20 +128,7 @@
|
||||
{#if messages && messages.length > 0}
|
||||
<div class="antiVSpacer x2" />
|
||||
<Scroller padding={'.5rem 1rem'}>
|
||||
<!-- TODO: FIXME -->
|
||||
<!-- <Messages
|
||||
messages={convertMessages(
|
||||
object,
|
||||
channel,
|
||||
messages,
|
||||
allIntegrations,
|
||||
$personAccountByIdStore,
|
||||
$employeeByIdStore
|
||||
)}
|
||||
{selectable}
|
||||
bind:selected
|
||||
on:select
|
||||
/> -->
|
||||
<Messages messages={convertMessages(object, channel, messages)} {selectable} bind:selected on:select />
|
||||
<div class="antiVSpacer x2" />
|
||||
</Scroller>
|
||||
<div class="antiVSpacer x2" />
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { getMetadata, translate } from '@hcengineering/platform'
|
||||
import { Button, Html, IconClose, Label, themeStore } from '@hcengineering/ui'
|
||||
import { Button, Html, IconClose, Label, themeStore, IconError } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import gmail from '../plugin'
|
||||
import { concatLink } from '@hcengineering/core'
|
||||
@@ -25,25 +26,34 @@
|
||||
|
||||
let connecting = false
|
||||
const gmailUrl = getMetadata(gmail.metadata.GmailURL) ?? ''
|
||||
let error = false
|
||||
|
||||
async function sendRequest (): Promise<void> {
|
||||
connecting = true
|
||||
const link = concatLink(gmailUrl, '/signin')
|
||||
const url = new URL(link)
|
||||
url.search = new URLSearchParams({
|
||||
redirectURL: (getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin) + window.location.pathname
|
||||
}).toString()
|
||||
try {
|
||||
connecting = true
|
||||
const link = concatLink(gmailUrl, '/signin')
|
||||
const url = new URL(link)
|
||||
url.search = new URLSearchParams({
|
||||
redirectURL: (getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin) + window.location.pathname
|
||||
}).toString()
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + getMetadata(presentation.metadata.Token),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const redirectTo = await res.text()
|
||||
window.open(redirectTo)
|
||||
dispatch('close')
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + getMetadata(presentation.metadata.Token),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const redirectTo = await res.text()
|
||||
window.open(redirectTo)
|
||||
dispatch('close')
|
||||
} catch (e: any) {
|
||||
Analytics.handleError(e)
|
||||
console.error(e)
|
||||
error = true
|
||||
} finally {
|
||||
connecting = false
|
||||
}
|
||||
}
|
||||
|
||||
let label = ''
|
||||
@@ -73,6 +83,13 @@
|
||||
<Html value={label} />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="flex-row-center gap-2 pt-2">
|
||||
<IconError size={'medium'} />
|
||||
<Label label={gmail.string.FailedToConnect} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="footer">
|
||||
<Button label={gmail.string.Connect} kind={'primary'} disabled={connecting} on:click={sendRequest} />
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import contact, { Channel, Contact, getName } from '@hcengineering/contact'
|
||||
import { employeeByIdStore } from '@hcengineering/contact-resources'
|
||||
import { Ref } from '@hcengineering/core'
|
||||
import { Ref, getCurrentAccount } from '@hcengineering/core'
|
||||
import { Message, SharedMessage } from '@hcengineering/gmail'
|
||||
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
@@ -49,8 +48,8 @@
|
||||
let currentMessage: SharedMessage | undefined = undefined
|
||||
|
||||
let newMessage: boolean = false
|
||||
const allIntegrations: Integration[] = []
|
||||
const integrations: Integration[] = []
|
||||
let allIntegrations: Integration[] = []
|
||||
let integrations: Integration[] = []
|
||||
let selectedIntegration: Integration | undefined = undefined
|
||||
|
||||
channel && inboxClient.forceReadDoc(channel._id, channel._class)
|
||||
@@ -96,20 +95,30 @@
|
||||
|
||||
$: templateProvider && selectedIntegration && templateProvider.set(setting.class.Integration, selectedIntegration)
|
||||
|
||||
// TODO: FIXME
|
||||
settingsQuery.query(setting.class.Integration, { type: gmail.integrationType.Gmail }, (res) => {
|
||||
// allIntegrations = res.filter((p) => !p.disabled && p.value !== '')
|
||||
// integrations = allIntegrations.filter((p) => p.createdBy === me || p.shared?.includes(me))
|
||||
// selectedIntegration = integrations.find((p) => p.createdBy === me) ?? integrations[0]
|
||||
})
|
||||
settingsQuery.query(
|
||||
setting.class.Integration,
|
||||
{
|
||||
type: gmail.integrationType.Gmail,
|
||||
disabled: false
|
||||
},
|
||||
(res) => {
|
||||
allIntegrations = res.filter((p) => !p.disabled && p.value !== '')
|
||||
const account = getCurrentAccount()
|
||||
const socialIds = getCurrentAccount().socialIds
|
||||
const isAvailable = (p: Integration): boolean => {
|
||||
const isOwner = p.createdBy !== undefined && socialIds.includes(p.createdBy)
|
||||
const shared = p.shared?.includes(account.uuid) ?? false
|
||||
return isOwner || shared
|
||||
}
|
||||
integrations = allIntegrations.filter(isAvailable)
|
||||
selectedIntegration = integrations.find((p) => p.createdBy === account.primarySocialId) ?? integrations[0]
|
||||
}
|
||||
)
|
||||
|
||||
// TODO: FIXME
|
||||
// $: gmailMessage &&
|
||||
// channel &&
|
||||
// object &&
|
||||
// convertMessage(object, channel, gmailMessage, allIntegrations, $personAccountByIdStore, $employeeByIdStore).then(
|
||||
// (p) => (currentMessage = p)
|
||||
// )
|
||||
$: gmailMessage &&
|
||||
channel &&
|
||||
object &&
|
||||
convertMessage(object, channel, gmailMessage).then((p) => (currentMessage = p))
|
||||
</script>
|
||||
|
||||
{#if channel && object}
|
||||
@@ -154,14 +163,7 @@
|
||||
{:else if currentMessage}
|
||||
<FullMessage {currentMessage} bind:newMessage on:close={back} />
|
||||
{:else}
|
||||
<Chats
|
||||
{object}
|
||||
{channel}
|
||||
bind:newMessage
|
||||
{allIntegrations}
|
||||
enabled={integrations.length > 0}
|
||||
on:select={selectHandler}
|
||||
/>
|
||||
<Chats {object} {channel} bind:newMessage enabled={integrations.length > 0} on:select={selectHandler} />
|
||||
{/if}
|
||||
</Dialog>
|
||||
{/if}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<bool
|
||||
return true
|
||||
}
|
||||
|
||||
// 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,}))/
|
||||
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<Employee>
|
||||
): SharedMessage[] {
|
||||
// TODO: FIXME
|
||||
throw new Error('Not implemented')
|
||||
// return messages.map((m) => {
|
||||
// return {
|
||||
// ...m,
|
||||
// _id: m._id as string as Ref<SharedMessage>,
|
||||
// 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<SharedMessage>,
|
||||
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<Employee>
|
||||
): Promise<SharedMessage> {
|
||||
// TODO: FIXME
|
||||
throw new Error('Not implemented')
|
||||
// return {
|
||||
// ...message,
|
||||
// _id: message._id as string as Ref<SharedMessage>,
|
||||
// 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<SharedMessage> {
|
||||
return {
|
||||
...message,
|
||||
_id: message._id as string as Ref<SharedMessage>,
|
||||
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<Employee>,
|
||||
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<Employee>) : 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<Employee>) : 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<Message>, doc?: Message): Promise<string> {
|
||||
|
||||
@@ -2472,6 +2472,11 @@
|
||||
"packageName": "@hcengineering/model-achievement",
|
||||
"projectFolder": "models/achievement",
|
||||
"shouldPublish": false
|
||||
},
|
||||
{
|
||||
"packageName": "@hcengineering/kvs-client",
|
||||
"projectFolder": "packages/kvs-client",
|
||||
"shouldPublish": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<SocialId | null> {
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
verifyAllowedServices(['telegram-bot'], extra)
|
||||
verifyAllowedServices(['telegram-bot', 'gmail'], extra)
|
||||
|
||||
const { socialKey } = params
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.env
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '<br><br><p>Sent via <a href="https://huly.io">Huly</a></p>',
|
||||
InitLimit: 50
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -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<Attachment>,
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
file: 'test-file' as Ref<Blob>,
|
||||
size: 0,
|
||||
lastModified: 0,
|
||||
attachedTo: '' as Ref<Doc>,
|
||||
attachedToClass: '' as Ref<Class<Doc>>,
|
||||
collection: '',
|
||||
space: '' as Ref<Space>,
|
||||
modifiedOn: 0,
|
||||
modifiedBy: '' as PersonId,
|
||||
_class: '' as Ref<Class<Attachment>>
|
||||
})
|
||||
|
||||
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<Attachment>,
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
file: 'test-file' as Ref<Blob>
|
||||
}
|
||||
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('<br><br><p>Sent via <a href="https://huly.io">Huly</a></p>')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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: '<p>Test content</p>'
|
||||
}
|
||||
|
||||
// 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))
|
||||
})
|
||||
})
|
||||
@@ -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<gmail_v1.Schema$Message> => ({
|
||||
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<Message> => ({
|
||||
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 = '<p>This is <b>bold</b> and <i>italic</i> text</p>'
|
||||
const expected = 'This is bold and italic text'
|
||||
expect(sanitizeText(html)).toBe(expected)
|
||||
})
|
||||
|
||||
it('should handle nested tags properly', () => {
|
||||
const html = '<div><p>Nested <span>tags <strong>should</strong> be</span> removed</p></div>'
|
||||
const expected = 'Nested tags should be removed'
|
||||
expect(sanitizeText(html)).toBe(expected)
|
||||
})
|
||||
|
||||
it('should handle malformed HTML', () => {
|
||||
const html = 'Text with <unclosed tag and <p>paragraph</p>'
|
||||
const expected = 'Text with paragraph'
|
||||
expect(sanitizeText(html)).toBe(expected)
|
||||
})
|
||||
|
||||
it('should handle scripts and potential XSS attacks', () => {
|
||||
const html = 'Hello <script>alert("XSS");</script> world'
|
||||
const expected = 'Hello world'
|
||||
expect(sanitizeText(html)).toBe(expected)
|
||||
})
|
||||
|
||||
it('should remove style tags and attributes', () => {
|
||||
const html = '<p style="color:red">Styled text</p>'
|
||||
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 = '<p>This is <b>bold</b> and <i>italic</i> text</p>'
|
||||
const expected = 'This is bold and italic text'
|
||||
expect(sanitizeText(html)).toBe(expected)
|
||||
})
|
||||
|
||||
it('should handle nested tags properly', () => {
|
||||
const html = '<div><p>Nested <span>tags <strong>should</strong> be</span> removed</p></div>'
|
||||
const expected = 'Nested tags should be removed'
|
||||
expect(sanitizeText(html)).toBe(expected)
|
||||
})
|
||||
|
||||
it('should handle malformed HTML', () => {
|
||||
const html = 'Text with <unclosed tag and <p>paragraph</p>'
|
||||
const expected = 'Text with paragraph'
|
||||
expect(sanitizeText(html)).toBe(expected)
|
||||
})
|
||||
|
||||
it('should handle scripts and potential XSS attacks', () => {
|
||||
const html = 'Hello <script>alert("XSS");</script> world'
|
||||
const expected = 'Hello world'
|
||||
expect(sanitizeText(html)).toBe(expected)
|
||||
})
|
||||
|
||||
it('should remove style tags and attributes', () => {
|
||||
const html = '<p style="color:red">Styled text</p>'
|
||||
const expected = 'Styled text'
|
||||
expect(sanitizeText(html)).toBe(expected)
|
||||
})
|
||||
|
||||
it('should handle empty input', () => {
|
||||
expect(sanitizeText('')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -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<MessageManager>
|
||||
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<MeasureContext>
|
||||
|
||||
mockMessageManager = {
|
||||
saveMessage: jest.fn().mockResolvedValue(undefined)
|
||||
} as unknown as jest.Mocked<MessageManager>
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<AccountClient>
|
||||
// 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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<Person | undefined> {
|
||||
try {
|
||||
const accountClient = getAccountClient(generateToken(account))
|
||||
return await accountClient.getPerson()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function getAccountSocialIds (account: AccountUuid): Promise<SocialId[]> {
|
||||
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<PersonId> {
|
||||
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<SocialId> {
|
||||
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
|
||||
}
|
||||
@@ -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<Config> = {
|
||||
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] ?? '<br><br><p>Sent via <a href="https://huly.io">Huly</a></p>'
|
||||
FooterMessage: process.env[envMap.FooterMessage] ?? '<br><br><p>Sent via <a href="https://huly.io">Huly</a></p>',
|
||||
KvsUrl: process.env[envMap.KvsUrl]
|
||||
}
|
||||
|
||||
const missingEnv = (Object.keys(params) as Array<keyof Config>)
|
||||
|
||||
@@ -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<gmail_v1.Schema$Message>,
|
||||
me: string
|
||||
): AttachedData<Message> & { 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<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
@@ -146,56 +73,116 @@ async function wait (sec: number): Promise<void> {
|
||||
}
|
||||
|
||||
export class GmailClient {
|
||||
private readonly oAuth2Client: OAuth2Client
|
||||
private readonly gmail: gmail_v1.Resource$Users
|
||||
private me: string | undefined = undefined
|
||||
private readonly tokens: Collection<Token>
|
||||
private readonly histories: Collection<History>
|
||||
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<void> | 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<Token>('tokens')
|
||||
this.histories = mongo.collection<History>('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<GmailClient> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const query = `{from:${value} to:${value} cc:${value} bcc:${value}}`
|
||||
await this.fullSync(query)
|
||||
}
|
||||
|
||||
private async getMe (): Promise<string> {
|
||||
if (this.me !== undefined) {
|
||||
return this.me
|
||||
private async getEmail (): Promise<string> {
|
||||
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<Token | null> {
|
||||
return await this.tokens.findOne({
|
||||
userId: this.user.userId,
|
||||
workspace: this.user.workspace
|
||||
})
|
||||
private async syncUserInfo (credentials: Credentials): Promise<void> {
|
||||
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<void> {
|
||||
await this.tokens.updateOne(
|
||||
{
|
||||
userId: this.user.userId,
|
||||
workspace: this.user.workspace
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
...token
|
||||
}
|
||||
}
|
||||
)
|
||||
private async getCurrentToken (): Promise<Token | null> {
|
||||
return await this.tokenStorage.getToken(this.socialId._id)
|
||||
}
|
||||
|
||||
private async addClient (): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<History | null> {
|
||||
return await this.histories.findOne({
|
||||
userId: this.user.userId,
|
||||
workspace: this.user.workspace
|
||||
})
|
||||
}
|
||||
|
||||
private async clearHistory (): Promise<void> {
|
||||
await this.histories.deleteOne({
|
||||
userId: this.user.userId,
|
||||
workspace: this.user.workspace
|
||||
})
|
||||
}
|
||||
|
||||
private async setHistoryId (historyId: string): Promise<void> {
|
||||
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<void> {
|
||||
let pageToken: string | undefined
|
||||
let histories: GaxiosResponse<gmail_v1.Schema$ListHistoryResponse>
|
||||
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<void> {
|
||||
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<GaxiosResponse<gmail_v1.Schema$Message>> {
|
||||
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<Message>): 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<gmail_v1.Schema$Message>): Promise<void> {
|
||||
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<Message>)
|
||||
: TxProcessor.createDoc2Doc(tx as TxCreateDoc<Message>)
|
||||
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<void> {
|
||||
try {
|
||||
if (currentAttachemtns.findIndex((p) => p.name === file.name && p.lastModified === file.lastModified) !== -1) {
|
||||
return
|
||||
}
|
||||
const id = uuid()
|
||||
const data: AttachedData<Attachment> = {
|
||||
name: file.name,
|
||||
file: id as Ref<Blob>,
|
||||
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<AttachedFile[]> {
|
||||
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<gmail_v1.Schema$MessagePartBody> {
|
||||
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<AttachedFile[]> {
|
||||
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<Message> & { modifiedOn: Timestamp },
|
||||
factory: TxFactory,
|
||||
channel: Channel
|
||||
): TxCUD<Message> {
|
||||
const tx = factory.createTxCollectionCUD<Channel, Message>(
|
||||
channel._class,
|
||||
channel._id,
|
||||
channel.space,
|
||||
'items',
|
||||
factory.createTxCreateDoc<Message>(
|
||||
gmail.class.Message,
|
||||
core.space.Workspace,
|
||||
message as unknown as Data<Message>,
|
||||
undefined,
|
||||
message.modifiedOn
|
||||
),
|
||||
message.modifiedOn
|
||||
)
|
||||
return tx
|
||||
}
|
||||
|
||||
private updateTx (
|
||||
message: AttachedData<Message> & { modifiedOn: Timestamp },
|
||||
current: Message,
|
||||
factory: TxFactory,
|
||||
channel: Channel
|
||||
): TxCUD<Message> | undefined {
|
||||
const operations = diffAttributes(current, message)
|
||||
if (Object.keys(operations).length === 0) return undefined
|
||||
const tx = factory.createTxCollectionCUD<Channel, Message>(
|
||||
channel._class,
|
||||
channel._id,
|
||||
channel.space,
|
||||
'items',
|
||||
factory.createTxUpdateDoc<Message>(current._class, current.space, current._id, operations)
|
||||
)
|
||||
return tx
|
||||
await this.syncManager.fullSync(this.socialId._id, this.email, q)
|
||||
}
|
||||
|
||||
private async refreshToken (): Promise<void> {
|
||||
@@ -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<string> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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")
|
||||
}
|
||||
@@ -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<string, WorkspaceClient> = new Map<string, WorkspaceClient>()
|
||||
|
||||
private readonly credentials: ProjectCredentials
|
||||
private readonly clients: Map<string, GmailClient[]> = new Map<string, GmailClient[]>()
|
||||
private readonly clients: Map<PersonId, GmailClient[]> = new Map<PersonId, GmailClient[]>()
|
||||
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<void> {
|
||||
const tokens = await this.mongo.collection<Token>('tokens').find().toArray()
|
||||
const groups = new Map<string, Token[]>()
|
||||
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<void>((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<void>((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<void> {
|
||||
@@ -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<GmailClient> {
|
||||
/*
|
||||
async getGmailClient (userId: AccountUuid, workspace: WorkspaceUuid, token: string): Promise<GmailClient> {
|
||||
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<void> {
|
||||
getAuthProvider (): AuthProvider {
|
||||
return this.authProvider
|
||||
}
|
||||
|
||||
async signout (workspace: WorkspaceUuid, account: AccountUuid): Promise<void> {
|
||||
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<GmailClient> {
|
||||
async createClient (user: User | Token, authCode?: string): Promise<GmailClient> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Integration | null> {
|
||||
const client = getAccountClient(serviceToken())
|
||||
return await client.getIntegration({ kind: GMAIL_INTEGRATION, socialId, workspaceUuid: workspaceUuid ?? null })
|
||||
}
|
||||
|
||||
export async function getIntegrations (token: string): Promise<Integration[]> {
|
||||
const client = getAccountClient(token)
|
||||
return (await client.listIntegrations({ kind: GMAIL_INTEGRATION })) ?? []
|
||||
}
|
||||
|
||||
export async function getIntegrationByAccount (
|
||||
account: AccountUuid,
|
||||
workspaceUuid?: WorkspaceUuid
|
||||
): Promise<Integration | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const accountClient = getAccountClient(serviceToken())
|
||||
await accountClient.deleteIntegration(integration)
|
||||
}
|
||||
|
||||
export async function createIntegrationIfNotEsixts (socialId: PersonId, workspace: WorkspaceUuid): Promise<Integration> {
|
||||
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<void> {
|
||||
const client = getAccountClient(serviceToken())
|
||||
await client.updateIntegration({
|
||||
...integration,
|
||||
data: {
|
||||
disabled: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function enableIntegration (integration: Integration): Promise<void> {
|
||||
const client = getAccountClient(serviceToken())
|
||||
await client.updateIntegration({
|
||||
...integration,
|
||||
data: {
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function cleanIntegrations (
|
||||
ctx: MeasureContext,
|
||||
client: TxOperations,
|
||||
userId: AccountUuid,
|
||||
workspace: WorkspaceUuid
|
||||
): Promise<void> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
const asyncClose = async (): Promise<void> => {
|
||||
await gmailController.close()
|
||||
await storageAdapter.close()
|
||||
await closeDB()
|
||||
}
|
||||
|
||||
const shutdown = (): void => {
|
||||
|
||||
@@ -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<void> {
|
||||
try {
|
||||
if (currentAttachemtns.findIndex((p) => p.name === file.name && p.lastModified === file.lastModified) !== -1) {
|
||||
return
|
||||
}
|
||||
const id = uuid()
|
||||
const data: AttachedData<Attachment> = {
|
||||
name: file.name,
|
||||
file: id as Ref<Blob>,
|
||||
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<AttachedFile[]> {
|
||||
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<gmail_v1.Schema$MessagePartBody> {
|
||||
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<AttachedFile[]> {
|
||||
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<string> {
|
||||
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<string[]> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Message>): 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<gmail_v1.Schema$Message>, me: string): Promise<void> {
|
||||
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<Message>)
|
||||
: TxProcessor.createDoc2Doc(tx as TxCreateDoc<Message>)
|
||||
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<Message> & { modifiedOn: Timestamp },
|
||||
factory: TxFactory,
|
||||
channel: Channel
|
||||
): TxCUD<Message> {
|
||||
const tx = factory.createTxCollectionCUD<Channel, Message>(
|
||||
channel._class,
|
||||
channel._id,
|
||||
channel.space,
|
||||
'items',
|
||||
factory.createTxCreateDoc<Message>(
|
||||
gmail.class.Message,
|
||||
core.space.Workspace,
|
||||
message as unknown as Data<Message>,
|
||||
undefined,
|
||||
message.modifiedOn
|
||||
),
|
||||
message.modifiedOn
|
||||
)
|
||||
return tx
|
||||
}
|
||||
|
||||
private updateTx (
|
||||
message: AttachedData<Message> & { modifiedOn: Timestamp },
|
||||
current: Message,
|
||||
factory: TxFactory,
|
||||
channel: Channel
|
||||
): TxCUD<Message> | undefined {
|
||||
const operations = diffAttributes(current, message)
|
||||
if (Object.keys(operations).length === 0) return undefined
|
||||
const tx = factory.createTxCollectionCUD<Channel, Message>(
|
||||
channel._class,
|
||||
channel._id,
|
||||
channel.space,
|
||||
'items',
|
||||
factory.createTxUpdateDoc<Message>(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<gmail_v1.Schema$Message>,
|
||||
me: string
|
||||
): AttachedData<Message> & { 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()
|
||||
}
|
||||
}
|
||||
@@ -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<string, Promise<void>>()
|
||||
|
||||
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<void>((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<History | null> {
|
||||
const historyKey = this.getHistoryKey(userId)
|
||||
return await this.keyValueClient.getValue<History>(historyKey)
|
||||
}
|
||||
|
||||
private async clearHistory (userId: PersonId): Promise<void> {
|
||||
const historyKey = this.getHistoryKey(userId)
|
||||
await this.keyValueClient.deleteKey(historyKey)
|
||||
}
|
||||
|
||||
private async setHistoryId (userId: PersonId, historyId: string): Promise<void> {
|
||||
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<void> {
|
||||
if (userEmail === undefined) {
|
||||
throw new Error('Cannot sync without user email')
|
||||
}
|
||||
let pageToken: string | undefined
|
||||
let histories: GaxiosResponse<gmail_v1.Schema$ListHistoryResponse>
|
||||
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<void> {
|
||||
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<GaxiosResponse<gmail_v1.Schema$Message>> {
|
||||
await this.rateLimiter.take(5)
|
||||
return await this.gmail.messages.get({
|
||||
id,
|
||||
userId: 'me',
|
||||
format: 'FULL'
|
||||
})
|
||||
}
|
||||
|
||||
async sync (userId: PersonId, userEmail?: string): Promise<void> {
|
||||
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}`
|
||||
}
|
||||
}
|
||||
@@ -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<void> = async () => {
|
||||
clientRef.close()
|
||||
}
|
||||
@@ -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<Token | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.accountClient.deleteIntegrationSecret({
|
||||
key: SecretType.TOKEN,
|
||||
kind: GMAIL_INTEGRATION,
|
||||
socialId,
|
||||
workspaceUuid: this.workspace
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkspaceTokens (accountClient: AccountClient, workspace: WorkspaceUuid): Promise<Token[]> {
|
||||
const secrets = await accountClient.listIntegrationsSecrets({
|
||||
kind: GMAIL_INTEGRATION,
|
||||
workspaceUuid: workspace
|
||||
})
|
||||
return secrets.map((secret: { secret: string }) => JSON.parse(secret.secret))
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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<T = any> {
|
||||
public readonly promise: Promise<T>
|
||||
@@ -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<void> {
|
||||
await new Promise<void>((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
|
||||
}
|
||||
|
||||
@@ -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<WorkspaceClient> {
|
||||
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<GmailClient> {
|
||||
const current = this.getGmailClient(user.userId)
|
||||
async createGmailClient (user: User, authCode?: string): Promise<GmailClient> {
|
||||
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<PersonId> {
|
||||
// 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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
const client = this.clients.get(userId)
|
||||
async signoutBySocialId (socialId: PersonId, byError: boolean = false): Promise<number> {
|
||||
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<Client> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
// await this.signout(acc.email, true)
|
||||
// }
|
||||
private async deactivateUser (acc: Person): Promise<void> {
|
||||
if (acc.personUuid === undefined) return
|
||||
await this.signoutByAccountId(acc.personUuid as AccountUuid, true)
|
||||
}
|
||||
|
||||
private async txEmployeeHandler (tx: Tx): Promise<void> {
|
||||
// TODO: FIXME
|
||||
throw new Error('Not implemented')
|
||||
// if (tx._class !== core.class.TxUpdateDoc) return
|
||||
// const ctx = tx as TxUpdateDoc<Employee>
|
||||
// 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<Employee>
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user