UBERF-10408: New gmail integration (#8869)

* UBERF-10375: Fix full message sync

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10375: Clean up

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Init new gmail integration

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Rename package

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Add mail channel and thread master tags

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Rename

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Create messages in cards

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Use new messages

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Use mail tags and add mail-common

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Use tags for mails

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Clean up tests

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Fix tests

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Clean up

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Clean up

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Apply client fixes

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Fix communication version

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Use adapter for message manager

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Remove gmail-next and improve performance

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Fix dep mismatch

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Add prefix for new history

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Fix failed tests

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-10408: Fix composite emails and add channel cache

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2025-05-12 11:26:46 +07:00
committed by GitHub
parent 6807c9e5ab
commit a98b83d1e4
57 changed files with 3480 additions and 461 deletions
+51 -12
View File
@@ -430,6 +430,9 @@ importers:
'@rush-temp/mail-assets':
specifier: file:./projects/mail-assets.tgz
version: file:projects/mail-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))
'@rush-temp/mail-common':
specifier: file:./projects/mail-common.tgz
version: file:projects/mail-common.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))
'@rush-temp/mail-resources':
specifier: file:./projects/mail-resources.tgz
version: file:projects/mail-resources.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))
@@ -786,7 +789,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)
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)
'@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))
@@ -4372,6 +4375,10 @@ packages:
resolution: {integrity: sha512-EY+m2n2E2Sm4zD0/n7psqlOwOH/zSinZE0zDfPLKZCFMF++7wuZ7cpkEz9NbL5zELQwqA7sk/G/IwUVIEXB/Kg==, tarball: file:projects/mail-assets.tgz}
version: 0.0.0
'@rush-temp/mail-common@file:projects/mail-common.tgz':
resolution: {integrity: sha512-FxQudYPTiQ+buMQzXcnO4zW3IAAXrxwa7nncHFIMiu4Q95O7KOHV16AgXwJGVbOjlChACxxbBqFqQ0JvogKnkA==, tarball: file:projects/mail-common.tgz}
version: 0.0.0
'@rush-temp/mail-resources@file:projects/mail-resources.tgz':
resolution: {integrity: sha512-495MjItvq3ep8/hMbrw8ITDPZQ8oNC+5h1ZLTU0uCLUAOVgu1BFVpRtBZ8nFAIxK2Aks5JpKg9No2070QDjDaQ==, tarball: file:projects/mail-resources.tgz}
version: 0.0.0
@@ -4513,7 +4520,7 @@ packages:
version: 0.0.0
'@rush-temp/model-mail@file:projects/model-mail.tgz':
resolution: {integrity: sha512-ZJ0BKaCme1heHlFvWPZO419uFncRPU832X/ce17INounlmwjTR8O/V6DBbeSn+VPJnhVq9LIZurd7REYrP+bjA==, tarball: file:projects/model-mail.tgz}
resolution: {integrity: sha512-b8PJIg5C9jkWApkCCzBS2Zk5Yw4n0jvXAPVwalSWqo64mPcr1jNKzQE7PkwGtTezKvCOrrCIfHR5Fy1DGJnrIA==, tarball: file:projects/model-mail.tgz}
version: 0.0.0
'@rush-temp/model-media@file:projects/model-media.tgz':
@@ -4849,7 +4856,7 @@ packages:
version: 0.0.0
'@rush-temp/pod-gmail@file:projects/pod-gmail.tgz':
resolution: {integrity: sha512-mWpD7TVaoiHBcGFgqt2KoPioV7Uc/9Y7DXu3LKiwZBrUX6X+WR/77RcKq/gNGPbwNxCKOc+dDsscF5NKMhIqSg==, tarball: file:projects/pod-gmail.tgz}
resolution: {integrity: sha512-5sqBFg5VB9InzTjRtZOQewE63ntNzDTdE38tjiKPT4e387vjhGP+IWY1l8PUUs1h/DHNyyeqV6SSzmz/x+mYjQ==, tarball: file:projects/pod-gmail.tgz}
version: 0.0.0
'@rush-temp/pod-inbound-mail@file:projects/pod-inbound-mail.tgz':
@@ -19853,6 +19860,44 @@ snapshots:
- supports-color
- ts-node
'@rush-temp/mail-common@file:projects/mail-common.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))':
dependencies:
'@hcengineering/communication-rest-client': 0.1.178(typescript@5.7.3)
'@hcengineering/communication-types': 0.1.178(typescript@5.7.3)
'@tsconfig/node16': 1.0.4
'@types/express': 4.17.21
'@types/jest': 29.5.12
'@types/node': 20.11.19
'@types/sanitize-html': 2.15.0
'@types/turndown': 5.0.5
'@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.7.3)
'@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.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-node: 11.1.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))
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.7.3)
ts-node: 10.9.2(@types/node@20.11.19)(typescript@5.7.3)
turndown: 7.2.0
typescript: 5.7.3
uuid: 8.3.2
transitivePeerDependencies:
- '@babel/core'
- '@jest/types'
- '@swc/core'
- '@swc/wasm'
- babel-jest
- babel-plugin-macros
- node-notifier
- supports-color
'@rush-temp/mail-resources@file:projects/mail-resources.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))':
dependencies:
'@types/jest': 29.5.12
@@ -22240,7 +22285,7 @@ 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)':
'@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)':
dependencies:
'@tsconfig/node16': 1.0.4
'@types/cors': 2.8.17
@@ -22248,6 +22293,7 @@ snapshots:
'@types/jest': 29.5.12
'@types/node': 20.11.19
'@types/sanitize-html': 2.15.0
'@types/turndown': 5.0.5
'@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)
@@ -22269,30 +22315,23 @@ snapshots:
googleapis: 122.0.0(encoding@0.1.13)
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
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)
turndown: 7.2.0
typescript: 5.3.3
uuid: 8.3.2
transitivePeerDependencies:
- '@aws-sdk/credential-providers'
- '@babel/core'
- '@jest/types'
- '@mongodb-js/zstd'
- '@swc/core'
- '@swc/wasm'
- babel-jest
- babel-plugin-macros
- encoding
- gcp-metadata
- kerberos
- mongodb-client-encryption
- node-notifier
- snappy
- socks
- supports-color
'@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))':
+1 -1
View File
@@ -455,11 +455,11 @@ export default function buildModel (): Builder {
],
[surveyModel, surveyId],
[presenceModel, presenceId],
[mailModel, mailId],
[chatModel, chatId],
[inboxModel, inboxId],
[achievementModel, achievementId],
[communicationModel, communicationId],
[mailModel, mailId],
[serverCoreModel, serverCoreId],
[serverAttachmentModel, serverAttachmentId],
+2
View File
@@ -30,8 +30,10 @@
"dependencies": {
"@hcengineering/activity": "^0.6.0",
"@hcengineering/card": "^0.6.0",
"@hcengineering/chat": "^0.6.0",
"@hcengineering/chunter": "^0.6.20",
"@hcengineering/model": "^0.6.11",
"@hcengineering/model-card": "^0.6.0",
"@hcengineering/model-core": "^0.6.0",
"@hcengineering/core": "^0.6.32",
"@hcengineering/platform": "^0.6.11",
+25
View File
@@ -16,6 +16,7 @@
import core, { ClassifierKind, type Domain, IndexKind } from '@hcengineering/core'
import { type Builder, Index, Model, Prop, TypeString } from '@hcengineering/model'
import { TDoc } from '@hcengineering/model-core'
import chat from '@hcengineering/chat'
import view, { type Viewlet } from '@hcengineering/model-view'
import card from '@hcengineering/card'
@@ -31,6 +32,7 @@ const mailTag = 'Mail'
export { mailId } from '@hcengineering/mail'
export { default } from './plugin'
// TODO: UBERF-10525 Remove mail route, use KVS, remove createMailTag
@Model(mail.class.MailRoute, core.class.Doc, DOMAIN_MAIL)
export class TMailRoute extends TDoc implements MailRoute {
@Prop(TypeString(), mail.string.MailId)
@@ -46,6 +48,29 @@ export function createModel (builder: Builder): void {
builder.createModel(TMailRoute)
createMailTag(builder)
builder.createDoc(
card.class.Tag,
core.space.Model,
{
extends: chat.masterTag.Thread,
label: mail.string.MailTag,
kind: ClassifierKind.MIXIN,
icon: mail.icon.Mail
},
mail.tag.MailThread
)
builder.createDoc(
card.class.Tag,
core.space.Model,
{
extends: chat.masterTag.Channel,
label: mail.string.MailTag,
kind: ClassifierKind.MIXIN,
icon: mail.icon.Mail
},
mail.tag.MailChannel
)
createMailViewlet(builder)
}
+3
View File
@@ -13,4 +13,7 @@
<symbol id="sent" viewBox="0 0 12 12">
<path d="M10.1,2.9c0.1-0.4-0.3-0.8-0.7-0.6L1.7,5.6c-0.3,0.1-0.3,0.5,0,0.6l1.6,0.5c0.3,0.1,0.6,0,0.9-0.1l3.6-2.5 C7.9,4.1,8,4.3,7.9,4.4L5.3,7C5.1,7.3,5.1,7.7,5.4,7.9l2.9,1.8c0.3,0.2,0.7,0,0.8-0.4L10.1,2.9z"/>
</symbol>
<symbol id="mail" viewBox="0 0 12 12" style="fill-rule:evenodd;clip-rule:evenodd;">
<path d="M2.2,3.1C1.9,3.4,1.9,3.9,1.9,4.8v2.4c0,0.9,0,1.4,0.3,1.7c0.3,0.3,0.8,0.3,1.7,0.3h4.2c0.9,0,1.4,0,1.7-0.3 c0.3-0.3,0.3-0.8,0.3-1.7V4.8c0-0.9,0-1.4-0.3-1.7C9.5,2.8,9.1,2.8,8.1,2.8H3.9C2.9,2.8,2.5,2.8,2.2,3.1z M3.5,4.3 C3.3,4.1,3,4.2,2.8,4.4C2.7,4.6,2.7,4.9,3,5.1l2.2,1.5c0.5,0.3,1.2,0.3,1.7,0L9,5.1c0.2-0.2,0.3-0.5,0.1-0.7C9,4.2,8.7,4.1,8.5,4.3 L6.3,5.7c-0.2,0.1-0.4,0.1-0.6,0L3.5,4.3z"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

+2 -1
View File
@@ -8,6 +8,7 @@
"Reply": "Odpovědět",
"Date": "Datum",
"MailMessage": "Zpráva",
"MailMessages": "Zprávy"
"MailMessages": "Zprávy",
"MailTag": "Zpráva"
}
}
+2 -1
View File
@@ -8,6 +8,7 @@
"Reply": "Antworten",
"Date": "Datum",
"MailMessage": "Nachricht",
"MailMessages": "Nachrichten"
"MailMessages": "Nachrichten",
"MailTag": "E-Mail"
}
}
+2 -1
View File
@@ -8,6 +8,7 @@
"Reply": "Reply",
"Date": "Date",
"MailMessage": "Message",
"MailMessages": "Messages"
"MailMessages": "Messages",
"MailTag": "Mail"
}
}
+2 -1
View File
@@ -8,6 +8,7 @@
"Reply": "Responder",
"Date": "Fecha",
"MailMessage": "Mensaje",
"MailMessages": "Mensajes"
"MailMessages": "Mensajes",
"MailTag": "Correo"
}
}
+2 -1
View File
@@ -8,6 +8,7 @@
"Reply": "Répondre",
"Date": "Date",
"MailMessage": "Message",
"MailMessages": "Messages"
"MailMessages": "Messages",
"MailTag": "Courrier"
}
}
+2 -1
View File
@@ -8,6 +8,7 @@
"Reply": "Rispondi",
"Date": "Data",
"MailMessage": "Messaggio",
"MailMessages": "Messaggi"
"MailMessages": "Messaggi",
"MailTag": "E-mail"
}
}
+2 -1
View File
@@ -8,6 +8,7 @@
"Reply": "返信",
"Date": "日付",
"MailMessage": "メッセージ",
"MailMessages": "メッセージ"
"MailMessages": "メッセージ",
"MailTag": "メール"
}
}
+2 -1
View File
@@ -8,6 +8,7 @@
"Reply": "Responder",
"Date": "Data",
"MailMessage": "Mensagem",
"MailMessages": "Mensagens"
"MailMessages": "Mensagens",
"MailTag": "Correio"
}
}
+2 -1
View File
@@ -8,6 +8,7 @@
"Reply": "Ответить",
"Date": "Дата",
"MailMessage": "Сообщение",
"MailMessages": "Сообщения"
"MailMessages": "Сообщения",
"MailTag": "Почта"
}
}
+2 -1
View File
@@ -8,6 +8,7 @@
"Reply": "回复",
"Date": "日期",
"MailMessage": "邮件",
"MailMessages": "邮件"
"MailMessages": "邮件",
"MailTag": "邮件"
}
}
+2 -1
View File
@@ -21,5 +21,6 @@ loadMetadata(mail.icon, {
NewMail: `${icons}#new-mail`,
Inbox: `${icons}#inbox`,
Done: `${icons}#done`,
Sent: `${icons}#sent`
Sent: `${icons}#sent`,
Mail: `${icons}#mail`
})
+9 -3
View File
@@ -16,7 +16,7 @@
import type { Class, Doc, Ref } from '@hcengineering/core'
import { Asset, IntlString, type Plugin, plugin } from '@hcengineering/platform'
import type { AnyComponent } from '@hcengineering/ui'
import type { MasterTag } from '@hcengineering/card'
import type { MasterTag, Tag } from '@hcengineering/card'
export interface MailRoute extends Doc {
mailId: string
@@ -33,6 +33,10 @@ export default plugin(mailId, {
MailThread: '' as Ref<MasterTag>,
MailRoute: '' as Ref<Class<MailRoute>>
},
tag: {
MailChannel: '' as Ref<Tag>,
MailThread: '' as Ref<Tag>
},
component: {
CreateMail: '' as AnyComponent,
MailThreadPresenter: '' as AnyComponent,
@@ -50,12 +54,14 @@ export default plugin(mailId, {
From: '' as IntlString,
CreateMail: '' as IntlString,
Reply: '' as IntlString,
Date: '' as IntlString
Date: '' as IntlString,
MailTag: '' as IntlString
},
icon: {
NewMail: '' as Asset,
Inbox: '' as Asset,
Done: '' as Asset,
Sent: '' as Asset
Sent: '' as Asset,
Mail: '' as Asset
}
})
+5
View File
@@ -2497,6 +2497,11 @@
"packageName": "@hcengineering/model-communication",
"projectFolder": "models/communication",
"shouldPublish": false
},
{
"packageName": "@hcengineering/mail-common",
"projectFolder": "services/mail/mail-common",
"shouldPublish": false
}
]
}
+5 -3
View File
@@ -17,7 +17,7 @@
"_phase:bundle": "rushx bundle",
"_phase:docker-build": "rushx docker:build",
"_phase:docker-staging": "rushx docker:staging",
"bundle": "node ../../../common/scripts/esbuild.js",
"bundle": "node ../../../common/scripts/esbuild.js --external=ws",
"docker:build": "../../../common/scripts/docker_build.sh hardcoreeng/gmail",
"docker:tbuild": "docker build -t hardcoreeng/gmail . --platform=linux/amd64 && ../../../common/scripts/docker_tag_push.sh hardcoreeng/gmail",
"docker:staging": "../../../common/scripts/docker_tag.sh hardcoreeng/gmail staging",
@@ -58,10 +58,13 @@
"dependencies": {
"@hcengineering/attachment": "^0.6.14",
"@hcengineering/account-client": "^0.6.0",
"@hcengineering/api-client": "^0.6.0",
"@hcengineering/card": "^0.6.0",
"@hcengineering/chat": "^0.6.0",
"@hcengineering/client": "^0.6.18",
"@hcengineering/client-resources": "^0.6.27",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/mongo": "^0.6.1",
"@hcengineering/mail-common": "^0.6.0",
"@hcengineering/core": "^0.6.32",
"@hcengineering/gmail": "^0.6.22",
"@hcengineering/platform": "^0.6.11",
@@ -80,7 +83,6 @@
"google-auth-library": "^8.0.2",
"gaxios": "^5.0.1",
"jwt-simple": "^0.5.6",
"mongodb": "^6.12.0",
"uuid": "^8.3.2",
"@hcengineering/analytics-service": "^0.6.0",
"sanitize-html": "^2.15.0"
@@ -12,7 +12,7 @@ import {
import { StorageAdapter } from '@hcengineering/server-core'
import { gmail_v1 } from 'googleapis'
import { AttachmentHandler } from '../message/attachments'
import type { AttachedFile } from '../types'
import type { Attachment as AttachedFile } from '@hcengineering/mail-common'
import attachment, { Attachment } from '@hcengineering/attachment'
import { decode64, encode64 } from '../base64'
@@ -83,9 +83,10 @@ describe('AttachmentHandler', () => {
describe('addAttachement', () => {
it('should not add attachment if it already exists', async () => {
const file: AttachedFile = {
file: 'test-file',
id: 'test-id',
data: Buffer.from('test-file'),
name: 'test.txt',
type: 'text/plain',
contentType: 'text/plain',
size: 100,
lastModified: Date.now()
}
@@ -106,9 +107,10 @@ describe('AttachmentHandler', () => {
it('should add new attachment', async () => {
const file: AttachedFile = {
file: 'test-file',
id: 'test-id',
data: Buffer.from('test-file'),
name: 'test.txt',
type: 'text/plain',
contentType: 'text/plain',
size: 100,
lastModified: Date.now()
}
@@ -154,9 +156,10 @@ describe('AttachmentHandler', () => {
expect(result).toEqual([
{
file: 'test-data',
id: expect.any(String),
data: expect.any(Buffer),
name: 'test.txt',
type: 'text/plain',
contentType: 'text/plain',
size: 100,
lastModified: expect.any(Number)
}
@@ -177,9 +180,10 @@ describe('AttachmentHandler', () => {
expect(result).toEqual([
{
file: 'test-data',
id: expect.any(String),
data: expect.any(Buffer),
name: 'test.txt',
type: 'text/plain',
contentType: 'text/plain',
size: 100,
lastModified: expect.any(Number)
}
@@ -56,6 +56,10 @@ jest.mock('@hcengineering/core', () => {
}
})
jest.mock('@hcengineering/mail-common', () => ({
createMessages: jest.fn().mockResolvedValue(undefined)
}))
jest.mock('googleapis', () => ({
gmail_v1: {},
google: {
@@ -69,7 +73,7 @@ jest.mock('googleapis', () => ({
}))
jest.mock('../tokens')
jest.mock('../message/message')
jest.mock('../message/adapter')
jest.mock('../message/sync')
jest.mock('../message/attachments')
jest.mock('@hcengineering/server-client', () => ({
@@ -0,0 +1,155 @@
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 { MessageManagerV1 } from '../message/v1/message'
/* eslint-disable @typescript-eslint/consistent-type-assertions */
/* eslint-disable @typescript-eslint/unbound-method */
jest.mock('../config')
describe('MessageManager', () => {
let messageManager: MessageManagerV1
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 MessageManagerV1(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')
})
})
})
@@ -1,159 +1,10 @@
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'
import { sanitizeText } from '../message/v2/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>'
@@ -13,12 +13,12 @@
// limitations under the License.
//
import { MeasureContext, PersonId } from '@hcengineering/core'
import { SyncManager, SyncMutex } from '../message/sync'
import { MessageManager } from '../message/message'
import { SyncManager } from '../message/sync'
import { MessageManagerV2 } from '../message/v2/message'
import { RateLimiter } from '../rateLimiter'
jest.mock('../config')
jest.mock('../message/message')
jest.mock('../message/adapter')
jest.mock('../utils', () => {
const originalModule = jest.requireActual('../utils')
return {
@@ -46,7 +46,7 @@ const mockKeyValueClientInstance = {
describe('SyncManager', () => {
// Mocked dependencies
let mockCtx: MeasureContext
let mockMessageManager: jest.Mocked<MessageManager>
let mockMessageManager: jest.Mocked<MessageManagerV2>
let mockGmail: any // Using any for easier mocking
let mockKeyValueClient: MockKeyValueClient
@@ -68,7 +68,7 @@ describe('SyncManager', () => {
mockMessageManager = {
saveMessage: jest.fn().mockResolvedValue(undefined)
} as unknown as jest.Mocked<MessageManager>
} as unknown as jest.Mocked<MessageManagerV2>
mockGmail = {
history: {
@@ -123,60 +123,6 @@ describe('SyncManager', () => {
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
@@ -237,101 +183,3 @@ describe('SyncManager', () => {
})
})
})
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,253 @@
//
// 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 { PersonId } from '@hcengineering/core'
import { KeyValueClient } from '@hcengineering/kvs-client'
import { SyncStateManager } from '../message/syncState'
import { IntegrationVersion } from '../types'
import { History } from '../message/types'
describe('SyncStateManager', () => {
const workspace = 'test-workspace'
const userId = 'test-user-id' as PersonId
const historyId = 'test-history-id'
const pageToken = 'test-page-token'
let mockKeyValueClient: jest.Mocked<KeyValueClient>
let v1StateManager: SyncStateManager
let v2StateManager: SyncStateManager
beforeEach(() => {
// Reset mocks before each test
mockKeyValueClient = {
getValue: jest.fn(),
setValue: jest.fn().mockResolvedValue(undefined),
deleteKey: jest.fn().mockResolvedValue(undefined)
} as unknown as jest.Mocked<KeyValueClient>
// Create state managers for both versions
v1StateManager = new SyncStateManager(mockKeyValueClient, workspace, IntegrationVersion.V1)
v2StateManager = new SyncStateManager(mockKeyValueClient, workspace, IntegrationVersion.V2)
})
describe('getHistory', () => {
it('should call getValue with correct key for V1', async () => {
const expectedHistory: History = {
historyId,
userId,
workspace
}
mockKeyValueClient.getValue.mockResolvedValue(expectedHistory)
const result = await v1StateManager.getHistory(userId)
expect(mockKeyValueClient.getValue).toHaveBeenCalledWith(`history:${workspace}:${userId}`)
expect(result).toEqual(expectedHistory)
})
it('should call getValue with correct key for V2', async () => {
const expectedHistory: History = {
historyId,
userId,
workspace
}
mockKeyValueClient.getValue.mockResolvedValue(expectedHistory)
const result = await v2StateManager.getHistory(userId)
expect(mockKeyValueClient.getValue).toHaveBeenCalledWith(`history-v2:${workspace}:${userId}`)
expect(result).toEqual(expectedHistory)
})
it('should return null when no history exists', async () => {
mockKeyValueClient.getValue.mockResolvedValue(null)
const result = await v1StateManager.getHistory(userId)
expect(result).toBeNull()
})
it('should propagate errors from KeyValueClient', async () => {
const error = new Error('Database error')
mockKeyValueClient.getValue.mockRejectedValue(error)
await expect(v1StateManager.getHistory(userId)).rejects.toThrow(error)
})
})
describe('clearHistory', () => {
it('should call deleteKey with correct key for V1', async () => {
await v1StateManager.clearHistory(userId)
expect(mockKeyValueClient.deleteKey).toHaveBeenCalledWith(`history:${workspace}:${userId}`)
})
it('should call deleteKey with correct key for V2', async () => {
await v2StateManager.clearHistory(userId)
expect(mockKeyValueClient.deleteKey).toHaveBeenCalledWith(`history-v2:${workspace}:${userId}`)
})
it('should propagate errors from KeyValueClient', async () => {
const error = new Error('Database error')
mockKeyValueClient.deleteKey.mockRejectedValue(error)
await expect(v1StateManager.clearHistory(userId)).rejects.toThrow(error)
})
})
describe('setHistoryId', () => {
it('should call setValue with correct key and value for V1', async () => {
await v1StateManager.setHistoryId(userId, historyId)
expect(mockKeyValueClient.setValue).toHaveBeenCalledWith(`history:${workspace}:${userId}`, {
historyId,
userId,
workspace
})
})
it('should call setValue with correct key and value for V2', async () => {
await v2StateManager.setHistoryId(userId, historyId)
expect(mockKeyValueClient.setValue).toHaveBeenCalledWith(`history-v2:${workspace}:${userId}`, {
historyId,
userId,
workspace
})
})
it('should propagate errors from KeyValueClient', async () => {
const error = new Error('Database error')
mockKeyValueClient.setValue.mockRejectedValue(error)
await expect(v1StateManager.setHistoryId(userId, historyId)).rejects.toThrow(error)
})
})
describe('getPageToken', () => {
it('should call getValue with correct key for V1', async () => {
mockKeyValueClient.getValue.mockResolvedValue(pageToken)
const result = await v1StateManager.getPageToken(userId)
expect(mockKeyValueClient.getValue).toHaveBeenCalledWith(`page-token:${workspace}:${userId}`)
expect(result).toEqual(pageToken)
})
it('should call getValue with correct key for V2', async () => {
mockKeyValueClient.getValue.mockResolvedValue(pageToken)
const result = await v2StateManager.getPageToken(userId)
expect(mockKeyValueClient.getValue).toHaveBeenCalledWith(`page-token-v2:${workspace}:${userId}`)
expect(result).toEqual(pageToken)
})
it('should return null when no page token exists', async () => {
mockKeyValueClient.getValue.mockResolvedValue(null)
const result = await v1StateManager.getPageToken(userId)
expect(result).toBeNull()
})
it('should propagate errors from KeyValueClient', async () => {
const error = new Error('Database error')
mockKeyValueClient.getValue.mockRejectedValue(error)
await expect(v1StateManager.getPageToken(userId)).rejects.toThrow(error)
})
})
describe('setPageToken', () => {
it('should call setValue with correct key and value for V1', async () => {
await v1StateManager.setPageToken(userId, pageToken)
expect(mockKeyValueClient.setValue).toHaveBeenCalledWith(`page-token:${workspace}:${userId}`, pageToken)
})
it('should call setValue with correct key and value for V2', async () => {
await v2StateManager.setPageToken(userId, pageToken)
expect(mockKeyValueClient.setValue).toHaveBeenCalledWith(`page-token-v2:${workspace}:${userId}`, pageToken)
})
it('should propagate errors from KeyValueClient', async () => {
const error = new Error('Database error')
mockKeyValueClient.setValue.mockRejectedValue(error)
await expect(v1StateManager.setPageToken(userId, pageToken)).rejects.toThrow(error)
})
})
describe('key generation', () => {
it('should generate different keys for V1 and V2', async () => {
// Test through the public methods to verify the keys are different
mockKeyValueClient.getValue.mockResolvedValue(null)
await v1StateManager.getHistory(userId)
await v2StateManager.getHistory(userId)
expect(mockKeyValueClient.getValue).toHaveBeenCalledWith(`history:${workspace}:${userId}`)
expect(mockKeyValueClient.getValue).toHaveBeenCalledWith(`history-v2:${workspace}:${userId}`)
mockKeyValueClient.getValue.mockClear()
await v1StateManager.getPageToken(userId)
await v2StateManager.getPageToken(userId)
expect(mockKeyValueClient.getValue).toHaveBeenCalledWith(`page-token:${workspace}:${userId}`)
expect(mockKeyValueClient.getValue).toHaveBeenCalledWith(`page-token-v2:${workspace}:${userId}`)
})
})
describe('version migration scenario', () => {
it('should allow migrating from V1 to V2', async () => {
// Simulate V1 data existing
const v1History: History = { historyId: 'v1-history', userId, workspace }
mockKeyValueClient.getValue.mockImplementation((key: string) => {
if (key === `history:${workspace}:${userId}`) {
return Promise.resolve(v1History)
}
if (key === `history-v2:${workspace}:${userId}`) {
return Promise.resolve(null)
}
return Promise.resolve(null)
})
// V1 manager should find history
const historyFromV1 = await v1StateManager.getHistory(userId)
expect(historyFromV1).toEqual(v1History)
// V2 manager should not find history yet
const historyFromV2 = await v2StateManager.getHistory(userId)
expect(historyFromV2).toBeNull()
// Migrate by setting V2 history
await v2StateManager.setHistoryId(userId, 'v2-history')
// Verify the call to set V2 history
expect(mockKeyValueClient.setValue).toHaveBeenCalledWith(`history-v2:${workspace}:${userId}`, {
historyId: 'v2-history',
userId,
workspace
})
})
})
})
+17 -5
View File
@@ -13,20 +13,21 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { BaseConfig } from '@hcengineering/mail-common'
import { config as dotenvConfig } from 'dotenv'
import { IntegrationVersion } from './types'
dotenvConfig()
interface Config {
interface Config extends BaseConfig {
Port: number
AccountsURL: string
ServiceID: string
Secret: string
Credentials: string
WATCH_TOPIC_NAME: string
FooterMessage: string
InitLimit: number
KvsUrl: string
Version: IntegrationVersion
}
const envMap: { [key in keyof Config]: string } = {
@@ -38,12 +39,21 @@ const envMap: { [key in keyof Config]: string } = {
WATCH_TOPIC_NAME: 'WATCH_TOPIC_NAME',
FooterMessage: 'FOOTER_MESSAGE',
InitLimit: 'INIT_LIMIT',
KvsUrl: 'KVS_URL'
KvsUrl: 'KVS_URL',
StorageConfig: 'STORAGE_CONFIG',
Version: 'VERSION'
}
const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined)
const config: Config = (() => {
const versionStr = process.env[envMap.Version] ?? 'v1'
let version: IntegrationVersion
if (versionStr === IntegrationVersion.V1 || versionStr === IntegrationVersion.V2) {
version = versionStr as IntegrationVersion
} else {
throw new Error(`Invalid version: ${versionStr}. Must be 'v1' or 'v2'.`)
}
const params: Partial<Config> = {
Port: parseNumber(process.env[envMap.Port]) ?? 8087,
AccountsURL: process.env[envMap.AccountsURL],
@@ -53,7 +63,9 @@ const config: Config = (() => {
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>',
KvsUrl: process.env[envMap.KvsUrl]
KvsUrl: process.env[envMap.KvsUrl],
StorageConfig: process.env[envMap.StorageConfig],
Version: version
}
const missingEnv = (Object.keys(params) as Array<keyof Config>)
+7 -5
View File
@@ -30,10 +30,11 @@ 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 { createMessageManager } from './message/adapter'
import { SyncManager } from './message/sync'
import { getEmail } from './gmail/utils'
import { Integration } from '@hcengineering/account-client'
import { IMessageManager } from './message/types'
const SCOPES = ['https://www.googleapis.com/auth/gmail.modify']
@@ -81,7 +82,7 @@ export class GmailClient {
private refreshTimer: NodeJS.Timeout | undefined = undefined
private readonly rateLimiter = new RateLimiter(1000, 200)
private readonly attachmentHandler: AttachmentHandler
private readonly messageManager: MessageManager
private readonly messageManager: IMessageManager
private readonly syncManager: SyncManager
private readonly integrationToken: string
private integration: Integration | undefined = undefined
@@ -104,12 +105,13 @@ export class GmailClient {
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(
this.messageManager = createMessageManager(
ctx,
this.client,
this.attachmentHandler,
this.socialId._id,
this.workspace
this.workspace,
this.integrationToken,
socialId
)
const keyValueClient = getKvsClient(this.integrationToken)
this.syncManager = new SyncManager(
@@ -0,0 +1,38 @@
//
// 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, TxOperations, SocialId } from '@hcengineering/core'
import config from '../config'
import { AttachmentHandler } from './attachments'
import { MessageManagerV2 } from './v2/message'
import { MessageManagerV1 } from './v1/message'
import { type IMessageManager } from './types'
import { type Channel } from '../types'
export function createMessageManager (
ctx: MeasureContext,
client: TxOperations,
attachmentHandler: AttachmentHandler,
workspace: { getChannel: (email: string) => Channel | undefined },
token: string,
socialId: SocialId
): IMessageManager {
if (config.Version === 'v2') {
return new MessageManagerV2(ctx, attachmentHandler, token, socialId)
} else {
return new MessageManagerV1(ctx, client, attachmentHandler, socialId._id, workspace)
}
}
@@ -12,13 +12,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { randomUUID } from 'crypto'
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 type { AttachedFile } from './types'
import { addFooter } from '../utils'
export class AttachmentHandler {
@@ -39,11 +40,11 @@ export class AttachmentHandler {
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,
type: file.data.toString('base64') ?? 'undefined',
size: file.size ?? file.data.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.storageAdapter.put(this.ctx, this.workspaceId as any, id, file.data, data.type, data.size) // TODO: FIXME
await this.client.addCollection(
attachment.class.Attachment,
message.space,
@@ -65,9 +66,10 @@ export class AttachmentHandler {
if (attachment.data == null) return []
return [
{
file: attachment.data,
id: randomUUID(),
name: part.filename,
type: part.mimeType ?? undefined,
data: Buffer.from(attachment.data, 'base64'),
contentType: part.mimeType ?? 'application/octet-stream',
size: attachment.size ?? undefined,
lastModified: new Date().getTime()
}
@@ -76,9 +78,10 @@ export class AttachmentHandler {
if (part.body?.data == null) return []
return [
{
file: part.body.data,
id: randomUUID(),
data: Buffer.from(part.body.data, 'base64'),
name: part.filename,
type: part.mimeType ?? undefined,
contentType: part.mimeType ?? 'application/octet-stream',
size: part.body.size ?? undefined,
lastModified: new Date().getTime()
}
+16 -83
View File
@@ -17,85 +17,26 @@ import { gmail_v1 } from 'googleapis'
import { type MeasureContext, PersonId } from '@hcengineering/core'
import { type KeyValueClient } from '@hcengineering/kvs-client'
import { SyncMutex } from '@hcengineering/mail-common'
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()
}
}
}
import { IMessageManager } from './types'
import { SyncStateManager } from './syncState'
import config from '../config'
export class SyncManager {
private readonly syncMutex = new SyncMutex()
private readonly stateManager: SyncStateManager
constructor (
private readonly ctx: MeasureContext,
private readonly messageManager: MessageManager,
private readonly messageManager: IMessageManager,
private readonly gmail: gmail_v1.Resource$Users,
private readonly workspace: string,
private readonly keyValueClient: KeyValueClient,
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 getPageToken (userId: PersonId): Promise<string | null> {
const pageTokenKey = this.getPageTokenKey(userId)
return await this.keyValueClient.getValue<string>(pageTokenKey)
}
private async setPageToken (userId: PersonId, pageToken: string): Promise<void> {
const pageTokenKey = this.getPageTokenKey(userId)
await this.keyValueClient.setValue(pageTokenKey, pageToken)
) {
this.stateManager = new SyncStateManager(keyValueClient, workspace, config.Version)
}
private async partSync (userId: PersonId, userEmail: string | undefined, historyId: string): Promise<void> {
@@ -115,7 +56,7 @@ export class SyncManager {
})
} catch (err: any) {
this.ctx.error('Part sync get history error', { workspaceUuid: this.workspace, userId, error: err.message })
await this.clearHistory(userId)
await this.stateManager.clearHistory(userId)
void this.sync(userId)
return
}
@@ -141,7 +82,7 @@ export class SyncManager {
}
}
if (history.id != null) {
await this.setHistoryId(userId, history.id)
await this.stateManager.setHistoryId(userId, history.id)
}
}
if (nextPageToken == null) {
@@ -162,8 +103,8 @@ export class SyncManager {
throw new Error('Cannot sync without user email')
}
// Get saved page token if exists to resume sync
let pageToken: string | undefined = (await this.getPageToken(userId)) ?? undefined
// Get saved page token to continue from
let pageToken: string | undefined = (await this.stateManager.getPageToken(userId)) ?? undefined
const query: gmail_v1.Params$Resource$Users$Messages$List = {
userId: 'me',
@@ -218,11 +159,11 @@ export class SyncManager {
// Update page token for the next iteration
pageToken = messages.data.nextPageToken
query.pageToken = pageToken
await this.setPageToken(userId, pageToken)
await this.stateManager.setPageToken(userId, pageToken)
}
if (currentHistoryId != null) {
await this.setHistoryId(userId, currentHistoryId)
await this.stateManager.setHistoryId(userId, currentHistoryId)
}
this.ctx.info('Full sync finished', { workspaceUuid: this.workspace, userId, userEmail })
} catch (err) {
@@ -246,7 +187,7 @@ export class SyncManager {
try {
this.ctx.info('Sync history', { workspaceUuid: this.workspace, userId, userEmail })
const history = await this.getHistory(userId)
const history = await this.stateManager.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)
@@ -260,12 +201,4 @@ export class SyncManager {
releaseLock()
}
}
private getHistoryKey (userId: PersonId): string {
return `history:${this.workspace}:${userId}`
}
private getPageTokenKey (userId: PersonId): string {
return `page-token:${this.workspace}:${userId}`
}
}
@@ -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 { PersonId } from '@hcengineering/core'
import { type KeyValueClient } from '@hcengineering/kvs-client'
import { type History } from './types'
import { IntegrationVersion } from '../types'
/**
* Handles persistent storage for Gmail sync state
*/
export class SyncStateManager {
constructor (
private readonly keyValueClient: KeyValueClient,
private readonly workspace: string,
private readonly version: IntegrationVersion
) {}
async getHistory (userId: PersonId): Promise<History | null> {
const historyKey = this.getHistoryKey(userId)
return await this.keyValueClient.getValue<History>(historyKey)
}
async clearHistory (userId: PersonId): Promise<void> {
const historyKey = this.getHistoryKey(userId)
await this.keyValueClient.deleteKey(historyKey)
}
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)
}
async getPageToken (userId: PersonId): Promise<string | null> {
const pageTokenKey = this.getPageTokenKey(userId)
return await this.keyValueClient.getValue<string>(pageTokenKey)
}
async setPageToken (userId: PersonId, pageToken: string): Promise<void> {
const pageTokenKey = this.getPageTokenKey(userId)
await this.keyValueClient.setValue(pageTokenKey, pageToken)
}
private getHistoryKey (userId: PersonId): string {
if (this.version === IntegrationVersion.V2) {
return `history-v2:${this.workspace}:${userId}`
}
return `history:${this.workspace}:${userId}`
}
private getPageTokenKey (userId: PersonId): string {
if (this.version === IntegrationVersion.V2) {
return `page-token-v2:${this.workspace}:${userId}`
}
return `page-token:${this.workspace}:${userId}`
}
}
@@ -0,0 +1,42 @@
//
// 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'
export interface AttachedFile {
id: string
name: string
data: Buffer
contentType: string
size?: number
lastModified: number
}
export interface EmailContact {
email: string
firstName?: string
lastName?: string
photoUrl?: string | null
}
export interface History {
historyId: string
userId: string
workspace: string
}
export interface IMessageManager {
saveMessage: (message: GaxiosResponse<gmail_v1.Schema$Message>, me: string) => Promise<void>
}
@@ -32,15 +32,16 @@ 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'
import { IMessageManager } from '../types'
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 {
export class MessageManagerV1 implements IMessageManager {
constructor (
private readonly ctx: MeasureContext,
private readonly client: TxOperations,
@@ -0,0 +1,100 @@
//
// 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 sanitizeHtml from 'sanitize-html'
import { SocialId, type MeasureContext } from '@hcengineering/core'
import { createMessages, parseEmailHeader, parseNameFromEmailHeader, EmailMessage } from '@hcengineering/mail-common'
import { IMessageManager } from '../types'
import config from '../../config'
import { AttachmentHandler } from '../attachments'
import { decode64 } from '../../base64'
export class MessageManagerV2 implements IMessageManager {
constructor (
private readonly ctx: MeasureContext,
private readonly attachmentHandler: AttachmentHandler,
private readonly token: string,
private readonly socialId: SocialId
) {}
async saveMessage (message: GaxiosResponse<gmail_v1.Schema$Message>, me: string): Promise<void> {
const res = convertMessage(message, me)
const attachments = await this.attachmentHandler.getPartFiles(message.data.payload, message.data.id ?? '')
await createMessages(config, this.ctx, this.token, res, attachments, me, this.socialId)
}
}
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): EmailMessage {
const date = message.data.internalDate != null ? new Date(Number.parseInt(message.data.internalDate)) : new Date()
const from = parseNameFromEmailHeader(getHeaderValue(message.data.payload, 'From') ?? '')
const to = parseEmailHeader(getHeaderValue(message.data.payload, 'To') ?? '')
const copy = parseEmailHeader(getHeaderValue(message.data.payload, 'Cc') ?? '')
const incoming = !from.email.includes(me)
return {
modifiedOn: date.getTime(),
mailId: 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()
}
}
+5 -8
View File
@@ -37,14 +37,6 @@ export type State = User & {
redirectURL: string
}
export interface AttachedFile {
size?: number
file: string
type?: string
lastModified: number
name: string
}
export type Channel = Pick<PlatformChannel, 'value' | keyof Doc>
export type RequestType = 'get' | 'post'
@@ -75,3 +67,8 @@ export const GMAIL_INTEGRATION = 'gmail'
export enum SecretType {
TOKEN = 'token'
}
export enum IntegrationVersion {
V1 = 'v1', // Save messages in legacy format using gmail.class.Message
V2 = 'v2' // Save messages as thread cards and communication messages
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
+1
View File
@@ -0,0 +1 @@
.env
+4
View File
@@ -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"
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}
+62
View File
@@ -0,0 +1,62 @@
{
"name": "@hcengineering/mail-common",
"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.",
"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": {
"@hcengineering/platform-rig": "^0.6.0",
"@tsconfig/node16": "^1.0.4",
"@types/express": "^4.17.13",
"@types/jest": "^29.5.5",
"@types/node": "~20.11.16",
"@types/turndown": "^5.0.5",
"@types/sanitize-html": "^2.15.0",
"@types/uuid": "^8.3.1",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"esbuild": "^0.24.2",
"eslint": "^8.54.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-n": "^15.4.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.1.1",
"jest": "^29.7.0",
"prettier": "^3.1.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.8.0",
"typescript": "^5.3.3"
},
"dependencies": {
"@hcengineering/account-client": "^0.6.0",
"@hcengineering/api-client": "^0.6.0",
"@hcengineering/card": "^0.6.0",
"@hcengineering/chat": "^0.6.0",
"@hcengineering/communication-rest-client": "0.1.178",
"@hcengineering/communication-types": "0.1.178",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/core": "^0.6.32",
"@hcengineering/mail": "^0.6.0",
"@hcengineering/server-storage": "^0.6.0",
"sanitize-html": "^2.15.0",
"turndown": "^7.2.0",
"uuid": "^8.3.2"
}
}
@@ -0,0 +1,315 @@
//
// 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 { PersonId, Ref, WorkspaceUuid, MeasureContext, TxOperations, Doc, SocialId } from '@hcengineering/core'
import { PersonSpace } from '@hcengineering/contact'
import chat from '@hcengineering/chat'
import mail from '@hcengineering/mail'
import { ChannelCache, ChannelCacheFactory } from '../channel'
/* eslint-disable @typescript-eslint/unbound-method */
describe('ChannelCache', () => {
let mockCtx: jest.Mocked<MeasureContext>
let mockClient: jest.Mocked<TxOperations>
let channelCache: ChannelCache
const workspace = 'test-workspace' as WorkspaceUuid
const spaceId = 'test-space-id' as Ref<PersonSpace>
const emailAccount = 'test@example.com'
const participants: PersonId[] = ['person1', 'person2'] as PersonId[]
const socialId: SocialId = { _id: 'social-id' as PersonId } as any
const mockChannel = {
_id: 'channel-id' as Ref<Doc>,
title: emailAccount
}
const generatedId = 'generated-id' as Ref<Doc>
beforeEach(() => {
jest.clearAllMocks()
mockCtx = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
} as unknown as jest.Mocked<MeasureContext>
mockClient = {
findOne: jest.fn(),
createDoc: jest.fn(),
createMixin: jest.fn()
} as unknown as jest.Mocked<TxOperations>
channelCache = new ChannelCache(mockCtx, mockClient, workspace)
})
describe('getOrCreateChannel', () => {
it('should return cached channel if it exists', async () => {
// Set up a channel in the cache
const cache = (channelCache as any).cache
cache.set(`${spaceId}:${emailAccount}`, mockChannel._id)
const result = await channelCache.getOrCreateChannel(spaceId, participants, emailAccount, socialId)
expect(result).toBe(mockChannel._id)
expect(mockClient.findOne).not.toHaveBeenCalled()
})
it('should fetch existing channel if not in cache', async () => {
mockClient.findOne.mockResolvedValue(mockChannel as any)
const result = await channelCache.getOrCreateChannel(spaceId, participants, emailAccount, socialId)
expect(result).toBe(mockChannel._id)
expect(mockClient.findOne).toHaveBeenCalledWith(mail.tag.MailChannel, { title: emailAccount })
expect(mockClient.createDoc).not.toHaveBeenCalled()
expect(mockCtx.info).toHaveBeenCalledWith('Using existing channel', {
me: emailAccount,
space: spaceId,
channel: mockChannel._id
})
})
it('should create new channel if it does not exist', async () => {
// First findOne returns null (no existing channel)
// Second findOne (inside createNewChannel) also returns null
mockClient.findOne.mockResolvedValue(undefined)
mockClient.createDoc.mockResolvedValue(generatedId)
mockClient.createMixin.mockResolvedValue(undefined as any)
const result = await channelCache.getOrCreateChannel(spaceId, participants, emailAccount, socialId)
expect(result).toBe(generatedId)
expect(mockClient.findOne).toHaveBeenCalledTimes(2)
expect(mockClient.createDoc).toHaveBeenCalledWith(
chat.masterTag.Channel,
spaceId,
{
title: emailAccount,
private: true,
members: participants,
archived: false,
createdBy: socialId._id,
modifiedBy: socialId._id
},
expect.any(String),
expect.any(Number),
socialId._id
)
expect(mockClient.createMixin).toHaveBeenCalledWith(
expect.any(String),
chat.masterTag.Channel,
spaceId,
mail.tag.MailChannel,
{},
expect.any(Number),
socialId._id
)
})
it('should use existing channel if found after acquiring mutex lock', async () => {
// First findOne returns null (trigger createNewChannel)
// Second findOne inside createNewChannel returns the channel (simulate race condition handled)
mockClient.findOne.mockResolvedValueOnce(null as any).mockResolvedValueOnce(mockChannel as any)
const result = await channelCache.getOrCreateChannel(spaceId, participants, emailAccount, socialId)
expect(result).toBe(mockChannel._id)
expect(mockClient.findOne).toHaveBeenCalledTimes(2)
expect(mockClient.createDoc).not.toHaveBeenCalled()
expect(mockCtx.info).toHaveBeenCalledWith('Using existing channel (found after mutex lock)', {
me: emailAccount,
space: spaceId,
channel: mockChannel._id
})
})
it('should handle errors and remove failed lookup from cache', async () => {
const error = new Error('Database error')
mockClient.findOne.mockRejectedValue(error)
const result = await channelCache.getOrCreateChannel(spaceId, participants, emailAccount, socialId)
expect(result).toBeUndefined()
expect(mockCtx.error).toHaveBeenCalledWith('Failed to create channel', {
me: emailAccount,
space: spaceId,
workspace,
error: error.message
})
// Verify the cache doesn't contain the failed lookup
expect((channelCache as any).cache.has(`${spaceId}:${emailAccount}`)).toBe(false)
})
})
describe('clearCache', () => {
it('should clear cache for specific space and email', async () => {
// Set up some items in cache
const cache = (channelCache as any).cache
cache.set(`${spaceId}:${emailAccount}`, mockChannel._id)
cache.set(`${spaceId}:other@example.com`, 'another-id')
channelCache.clearCache(spaceId, emailAccount)
expect(cache.has(`${spaceId}:${emailAccount}`)).toBe(false)
expect(cache.has(`${spaceId}:other@example.com`)).toBe(true)
})
})
describe('clearAllCache', () => {
it('should clear all cached channels', async () => {
// Set up multiple items in cache
const cache = (channelCache as any).cache
cache.set(`${spaceId}:${emailAccount}`, mockChannel._id)
cache.set(`${spaceId}:other@example.com`, 'another-id')
cache.set(`other-space:${emailAccount}`, 'third-id')
channelCache.clearAllCache()
expect(cache.size).toBe(0)
})
})
describe('size', () => {
it('should return the number of cached channels', async () => {
const cache = (channelCache as any).cache
expect(channelCache.size).toBe(0)
cache.set(`${spaceId}:${emailAccount}`, mockChannel._id)
expect(channelCache.size).toBe(1)
cache.set(`${spaceId}:other@example.com`, 'another-id')
expect(channelCache.size).toBe(2)
channelCache.clearCache(spaceId, emailAccount)
expect(channelCache.size).toBe(1)
channelCache.clearAllCache()
expect(channelCache.size).toBe(0)
})
})
})
describe('ChannelCacheFactory', () => {
let mockCtx1: jest.Mocked<MeasureContext>
let mockClient1: jest.Mocked<TxOperations>
const workspace1 = 'workspace-1' as WorkspaceUuid
const workspace2 = 'workspace-2' as WorkspaceUuid
beforeEach(() => {
jest.clearAllMocks()
ChannelCacheFactory.resetAllInstances()
mockCtx1 = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
} as unknown as jest.Mocked<MeasureContext>
mockClient1 = {} as unknown as jest.Mocked<TxOperations>
})
describe('getInstance', () => {
it('should create a new instance for a workspace', () => {
expect(ChannelCacheFactory.instanceCount).toBe(0)
const cache = ChannelCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
expect(cache).toBeInstanceOf(ChannelCache)
expect(ChannelCacheFactory.instanceCount).toBe(1)
})
it('should return the same instance for the same workspace', () => {
const cache1 = ChannelCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
const cache2 = ChannelCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
expect(cache1).toBe(cache2)
expect(ChannelCacheFactory.instanceCount).toBe(1)
})
it('should create different instances for different workspaces', () => {
const cache1 = ChannelCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
const cache2 = ChannelCacheFactory.getInstance(mockCtx1, mockClient1, workspace2)
expect(cache1).not.toBe(cache2)
expect(ChannelCacheFactory.instanceCount).toBe(2)
})
})
describe('resetInstance', () => {
it('should remove the instance for a specific workspace', () => {
const cache1 = ChannelCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
const cache2 = ChannelCacheFactory.getInstance(mockCtx1, mockClient1, workspace2)
expect(ChannelCacheFactory.instanceCount).toBe(2)
ChannelCacheFactory.resetInstance(workspace1)
expect(ChannelCacheFactory.instanceCount).toBe(1)
// Getting workspace1 again should create a new instance
const cache1New = ChannelCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
expect(cache1New).not.toBe(cache1)
// Workspace2 instance should remain the same
const cache2Again = ChannelCacheFactory.getInstance(mockCtx1, mockClient1, workspace2)
expect(cache2Again).toBe(cache2)
})
it('should do nothing when resetting a non-existent workspace', () => {
ChannelCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
expect(ChannelCacheFactory.instanceCount).toBe(1)
// Reset a workspace that doesn't have a cache
ChannelCacheFactory.resetInstance('non-existent-workspace' as WorkspaceUuid)
expect(ChannelCacheFactory.instanceCount).toBe(1)
})
})
describe('resetAllInstances', () => {
it('should remove all workspace instances', () => {
ChannelCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
ChannelCacheFactory.getInstance(mockCtx1, mockClient1, workspace2)
expect(ChannelCacheFactory.instanceCount).toBe(2)
ChannelCacheFactory.resetAllInstances()
expect(ChannelCacheFactory.instanceCount).toBe(0)
})
})
describe('instanceCount', () => {
it('should return the number of workspace instances', () => {
expect(ChannelCacheFactory.instanceCount).toBe(0)
ChannelCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
expect(ChannelCacheFactory.instanceCount).toBe(1)
ChannelCacheFactory.getInstance(mockCtx1, mockClient1, workspace2)
expect(ChannelCacheFactory.instanceCount).toBe(2)
ChannelCacheFactory.resetInstance(workspace1)
expect(ChannelCacheFactory.instanceCount).toBe(1)
ChannelCacheFactory.resetAllInstances()
expect(ChannelCacheFactory.instanceCount).toBe(0)
})
})
})
@@ -0,0 +1,179 @@
//
// 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 { SyncMutex } from '../mutex'
describe('SyncMutex', () => {
let mutex: SyncMutex
beforeEach(() => {
mutex = new SyncMutex()
})
it('should allow a lock to be acquired and released', async () => {
const release = await mutex.lock('key1')
expect(typeof release).toBe('function')
release()
})
it('should allow different keys to be locked simultaneously', async () => {
const timestamps: number[] = []
void (async () => {
const release = await mutex.lock('key1')
timestamps.push(Date.now())
await new Promise((resolve) => setTimeout(resolve, 50))
release()
})()
void (async () => {
const release = await mutex.lock('key2')
timestamps.push(Date.now())
await new Promise((resolve) => setTimeout(resolve, 50))
release()
})()
// Give some time for both locks to be acquired
await new Promise((resolve) => setTimeout(resolve, 20))
// Both locks should be acquired within 20ms of each other
// since they use different keys and shouldn't block each other
expect(timestamps.length).toBe(2)
expect(Math.abs(timestamps[0] - timestamps[1])).toBeLessThan(20)
})
it('should make subsequent locks wait for release', async () => {
const executionOrder: string[] = []
// First lock
void (async () => {
const release = await mutex.lock('key1')
executionOrder.push('lock1 acquired')
await new Promise((resolve) => setTimeout(resolve, 50))
executionOrder.push('lock1 releasing')
release()
})()
// Small delay to ensure first lock is acquired first
await new Promise((resolve) => setTimeout(resolve, 10))
// Second lock on same key
void (async () => {
executionOrder.push('lock2 waiting')
const release = await mutex.lock('key1')
executionOrder.push('lock2 acquired')
release()
})()
// Wait for all operations to complete
await new Promise((resolve) => setTimeout(resolve, 100))
expect(executionOrder).toEqual(['lock1 acquired', 'lock2 waiting', 'lock1 releasing', 'lock2 acquired'])
})
it('should handle multiple sequential locks correctly', async () => {
const result: number[] = []
const lockAndExecute = async (value: number): Promise<void> => {
const release = await mutex.lock('sequential')
result.push(value)
await new Promise((resolve) => setTimeout(resolve, 10)) // Simulate some work
release()
}
// Execute multiple locks in sequence
await Promise.all([lockAndExecute(1), lockAndExecute(2), lockAndExecute(3), lockAndExecute(4), lockAndExecute(5)])
// The values should be added to the result array in order
// since each lock waits for the previous one to complete
expect(result).toEqual([1, 2, 3, 4, 5])
})
it('should handle lock release correctly even if called multiple times', async () => {
const lockKey = 'multiple-release'
const release = await mutex.lock(lockKey)
// First release
release()
// Second release should not throw
release()
// We should be able to acquire the lock again
const newRelease = await mutex.lock(lockKey)
newRelease()
})
it('should handle errors within the locked code', async () => {
const lockKey = 'error-handling'
const executionOrder: string[] = []
// First lock with an error
try {
const release = await mutex.lock(lockKey)
executionOrder.push('lock1 acquired')
try {
throw new Error('Simulated error')
} finally {
executionOrder.push('lock1 releasing')
release()
}
} catch (err) {
executionOrder.push('error caught')
}
// Second lock should still work
const release2 = await mutex.lock(lockKey)
executionOrder.push('lock2 acquired')
release2()
expect(executionOrder).toEqual(['lock1 acquired', 'lock1 releasing', 'error caught', 'lock2 acquired'])
})
it('should work with complex async operations', async () => {
const results: string[] = []
const lockKey = 'async-operations'
// Helper function that performs complex async work under lock
const performWork = async (id: string, delay: number): Promise<void> => {
const release = await mutex.lock(lockKey)
try {
results.push(`${id} started`)
await new Promise((resolve) => setTimeout(resolve, delay))
results.push(`${id} completed`)
} finally {
release()
}
}
// Start multiple work items with different delays
await Promise.all([performWork('task1', 30), performWork('task2', 10), performWork('task3', 20)])
// Tasks should be completed in the order they acquired the lock
expect(results.length).toBe(6)
expect(results.includes('task1 started')).toBe(true)
expect(results.includes('task1 completed')).toBe(true)
expect(results.includes('task2 started')).toBe(true)
expect(results.includes('task2 completed')).toBe(true)
expect(results.includes('task3 started')).toBe(true)
expect(results.includes('task3 completed')).toBe(true)
expect(results.indexOf('task1 started')).toBeLessThan(results.indexOf('task2 started'))
expect(results.indexOf('task2 started')).toBeLessThan(results.indexOf('task3 started'))
expect(results.indexOf('task1 completed')).toBeLessThan(results.indexOf('task2 completed'))
expect(results.indexOf('task2 completed')).toBeLessThan(results.indexOf('task3 completed'))
})
})
@@ -0,0 +1,237 @@
//
// 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 { parseEmailHeader } from '../utils'
describe('parseEmailHeader', () => {
it('should handle undefined input', () => {
const result = parseEmailHeader(undefined)
expect(result).toEqual([])
})
it('should handle empty string input', () => {
const result = parseEmailHeader('')
expect(result).toEqual([])
})
it('should handle whitespace-only input', () => {
const result = parseEmailHeader(' \t\n ')
expect(result).toEqual([])
})
it('should parse a single plain email address', () => {
const result = parseEmailHeader('test@example.com')
expect(result).toEqual([
{
email: 'test@example.com',
firstName: 'test',
lastName: 'example.com'
}
])
})
it('should parse a single email with name in angle brackets', () => {
const result = parseEmailHeader('John Doe <john.doe@example.com>')
expect(result).toEqual([
{
email: 'john.doe@example.com',
firstName: 'John',
lastName: 'Doe'
}
])
})
it('should parse a single email with quoted name in angle brackets', () => {
const result = parseEmailHeader('"John Doe" <john.doe@example.com>')
expect(result).toEqual([
{
email: 'john.doe@example.com',
firstName: 'John',
lastName: 'Doe'
}
])
})
it('should parse a single email with multi-word last name', () => {
const result = parseEmailHeader('John Doe Smith <john.doe@example.com>')
expect(result).toEqual([
{
email: 'john.doe@example.com',
firstName: 'John',
lastName: 'Doe Smith'
}
])
})
it('should parse multiple plain email addresses', () => {
const result = parseEmailHeader('test1@example.com, test2@example.com')
expect(result).toEqual([
{
email: 'test1@example.com',
firstName: 'test1',
lastName: 'example.com'
},
{
email: 'test2@example.com',
firstName: 'test2',
lastName: 'example.com'
}
])
})
it('should parse multiple email addresses with names', () => {
const result = parseEmailHeader('John <john@example.com>, Jane Doe <jane@example.com>')
expect(result).toEqual([
{
email: 'john@example.com',
firstName: 'John',
lastName: 'example.com'
},
{
email: 'jane@example.com',
firstName: 'Jane',
lastName: 'Doe'
}
])
})
it('should handle commas within quoted names', () => {
const result = parseEmailHeader('"Doe, John" <john@example.com>, Jane Doe <jane@example.com>')
expect(result).toEqual([
{
email: 'john@example.com',
firstName: 'Doe,',
lastName: 'John'
},
{
email: 'jane@example.com',
firstName: 'Jane',
lastName: 'Doe'
}
])
})
it('should handle mixed format addresses', () => {
const result = parseEmailHeader('john@example.com, Jane Doe <jane@example.com>')
expect(result).toEqual([
{
email: 'john@example.com',
firstName: 'john',
lastName: 'example.com'
},
{
email: 'jane@example.com',
firstName: 'Jane',
lastName: 'Doe'
}
])
})
it('should handle email addresses with no name part', () => {
const result = parseEmailHeader('<john@example.com>, <jane@example.com>')
expect(result).toEqual([
{
email: 'john@example.com',
firstName: 'john',
lastName: 'example.com'
},
{
email: 'jane@example.com',
firstName: 'jane',
lastName: 'example.com'
}
])
})
it('should handle extra whitespace between addresses', () => {
const result = parseEmailHeader('john@example.com , Jane Doe <jane@example.com>')
expect(result).toEqual([
{
email: 'john@example.com',
firstName: 'john',
lastName: 'example.com'
},
{
email: 'jane@example.com',
firstName: 'Jane',
lastName: 'Doe'
}
])
})
it('should handle the example from the prompt', () => {
const result = parseEmailHeader('example staff <example-staff@example.com>, personnel <personnel@example.com>')
expect(result).toEqual([
{
email: 'example-staff@example.com',
firstName: 'example',
lastName: 'staff'
},
{
email: 'personnel@example.com',
firstName: 'personnel',
lastName: 'example.com'
}
])
})
it('should handle another example from the prompt', () => {
const result = parseEmailHeader('abc@test.com, 123@test.com')
expect(result).toEqual([
{
email: 'abc@test.com',
firstName: 'abc',
lastName: 'test.com'
},
{
email: '123@test.com',
firstName: '123',
lastName: 'test.com'
}
])
})
it('should handle addresses with trailing comma', () => {
const result = parseEmailHeader('John <john@example.com>, Jane <jane@example.com>')
expect(result).toEqual([
{
email: 'john@example.com',
firstName: 'John',
lastName: 'example.com'
},
{
email: 'jane@example.com',
firstName: 'Jane',
lastName: 'example.com'
}
])
})
it('should handle empty addresses between commas', () => {
const result = parseEmailHeader('John <john@example.com>, , Jane <jane@example.com>')
expect(result).toEqual([
{
email: 'john@example.com',
firstName: 'John',
lastName: 'example.com'
},
{
email: 'jane@example.com',
firstName: 'Jane',
lastName: 'example.com'
}
])
})
})
@@ -0,0 +1,113 @@
import { parseNameFromEmailHeader } from '../utils'
import { EmailContact } from '../types'
describe('parseNameFromEmailHeader', () => {
it('should parse email with name in double quotes', () => {
const input = '"John Doe" <john.doe@example.com>'
const expected: EmailContact = {
email: 'john.doe@example.com',
firstName: 'John',
lastName: 'Doe'
}
expect(parseNameFromEmailHeader(input)).toEqual(expected)
})
it('should parse email with name without quotes', () => {
const input = 'Jane Smith <jane.smith@example.com>'
const expected: EmailContact = {
email: 'jane.smith@example.com',
firstName: 'Jane',
lastName: 'Smith'
}
expect(parseNameFromEmailHeader(input)).toEqual(expected)
})
it('should parse email without name', () => {
const input = 'no-reply@example.com'
const expected: EmailContact = {
email: 'no-reply@example.com',
firstName: 'no-reply',
lastName: 'example.com'
}
expect(parseNameFromEmailHeader(input)).toEqual(expected)
})
it('should parse email with angle brackets only', () => {
const input = '<support@example.com>'
const expected: EmailContact = {
email: 'support@example.com',
firstName: 'support',
lastName: 'example.com'
}
expect(parseNameFromEmailHeader(input)).toEqual(expected)
})
it('should parse email with multi-word last name', () => {
const input = 'Maria Van Der Berg <maria@example.com>'
const expected: EmailContact = {
email: 'maria@example.com',
firstName: 'Maria',
lastName: 'Van Der Berg'
}
expect(parseNameFromEmailHeader(input)).toEqual(expected)
})
it('should handle undefined input', () => {
const expected: EmailContact = {
email: '',
firstName: '',
lastName: ''
}
expect(parseNameFromEmailHeader(undefined)).toEqual(expected)
})
it('should handle empty string input', () => {
const input = ''
const expected: EmailContact = {
email: '',
firstName: '',
lastName: ''
}
expect(parseNameFromEmailHeader(input)).toEqual(expected)
})
it('should handle malformed email formats', () => {
const input = 'John Doe john.doe@example.com'
const expected: EmailContact = {
email: 'John Doe john.doe@example.com',
firstName: 'John Doe john.doe',
lastName: 'example.com'
}
expect(parseNameFromEmailHeader(input)).toEqual(expected)
})
it('should parse single-name format', () => {
const input = 'Support <help@example.com>'
const expected: EmailContact = {
email: 'help@example.com',
firstName: 'Support',
lastName: 'example.com'
}
expect(parseNameFromEmailHeader(input)).toEqual(expected)
})
it('should handle name with special characters', () => {
const input = '"O\'Neill, James" <james.oneill@example.com>'
const expected: EmailContact = {
email: 'james.oneill@example.com',
firstName: "O'Neill,",
lastName: 'James'
}
expect(parseNameFromEmailHeader(input)).toEqual(expected)
})
})
@@ -0,0 +1,181 @@
//
// 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, PersonUuid, SocialIdType } from '@hcengineering/core'
import { RestClient } from '@hcengineering/api-client'
import { PersonCache, CachedPerson } from '../person'
import { EmailContact } from '../types'
describe('PersonCache', () => {
let personCache: PersonCache
let mockCtx: MeasureContext
let mockRestClient: jest.Mocked<RestClient>
const mockContact: EmailContact = {
email: 'test@example.com',
firstName: 'Test',
lastName: 'User'
}
const mockPerson: CachedPerson = {
socialId: 'test-social-id' as PersonId,
uuid: 'test-uuid' as PersonUuid,
localPerson: 'test-local-person'
}
beforeEach(() => {
// Set up mocks
mockCtx = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
} as unknown as jest.Mocked<MeasureContext>
mockRestClient = {
ensurePerson: jest.fn().mockResolvedValue(mockPerson)
} as unknown as jest.Mocked<RestClient>
// Create the cache instance
personCache = new PersonCache(mockCtx, mockRestClient)
})
afterEach(() => {
jest.resetAllMocks()
})
describe('ensurePerson', () => {
it('should call ensurePerson on the RestClient and return the result', async () => {
const result = await personCache.ensurePerson(mockContact)
expect(result).toEqual(mockPerson)
expect(mockRestClient.ensurePerson).toHaveBeenCalledWith(
SocialIdType.EMAIL,
mockContact.email,
mockContact.firstName,
mockContact.lastName
)
})
it('should normalize email addresses by trimming and lowercasing', async () => {
const uppercaseContact: EmailContact = {
email: ' TEST@EXAMPLE.COM ',
firstName: 'Test',
lastName: 'User'
}
await personCache.ensurePerson(uppercaseContact)
expect(mockRestClient.ensurePerson).toHaveBeenCalledWith(
SocialIdType.EMAIL,
'test@example.com',
uppercaseContact.firstName,
uppercaseContact.lastName
)
})
it('should return cached result for subsequent calls with the same email', async () => {
await personCache.ensurePerson(mockContact)
await personCache.ensurePerson(mockContact)
expect(mockRestClient.ensurePerson).toHaveBeenCalledTimes(1)
})
it('should handle errors from ensurePerson and remove failed lookup from cache', async () => {
const error = new Error('Network error')
mockRestClient.ensurePerson.mockRejectedValueOnce(error)
await expect(personCache.ensurePerson(mockContact)).rejects.toThrow(error)
expect(mockCtx.error).toHaveBeenCalledWith('Error ensuring person exists', {
email: mockContact.email,
firstName: mockContact.firstName,
lastName: mockContact.lastName,
error: error.message
})
// The failed lookup should be removed from cache,
// so a subsequent call should retry the API call
mockRestClient.ensurePerson.mockResolvedValueOnce(mockPerson)
await personCache.ensurePerson(mockContact)
expect(mockRestClient.ensurePerson).toHaveBeenCalledTimes(2)
})
it('should cache different contacts separately', async () => {
const contact1: EmailContact = {
email: 'test1@example.com',
firstName: 'Test1',
lastName: 'User1'
}
const contact2: EmailContact = {
email: 'test2@example.com',
firstName: 'Test2',
lastName: 'User2'
}
const mockPerson1: CachedPerson = {
socialId: 'test-social-id-1' as PersonId,
uuid: 'test-uuid-1' as PersonUuid,
localPerson: 'test-local-person-1'
}
const mockPerson2: CachedPerson = {
socialId: 'test-social-id-2' as PersonId,
uuid: 'test-uuid-2' as PersonUuid,
localPerson: 'test-local-person-2'
}
mockRestClient.ensurePerson.mockResolvedValueOnce(mockPerson1).mockResolvedValueOnce(mockPerson2)
const result1 = await personCache.ensurePerson(contact1)
const result2 = await personCache.ensurePerson(contact2)
expect(result1).toEqual(mockPerson1)
expect(result2).toEqual(mockPerson2)
expect(mockRestClient.ensurePerson).toHaveBeenCalledTimes(2)
})
})
describe('clearCache', () => {
it('should clear the cache', async () => {
await personCache.ensurePerson(mockContact)
expect(personCache.size()).toBe(1)
personCache.clearCache()
expect(personCache.size()).toBe(0)
// Subsequent call should call the API again
await personCache.ensurePerson(mockContact)
expect(mockRestClient.ensurePerson).toHaveBeenCalledTimes(2)
})
})
describe('size', () => {
it('should return the number of cached entries', async () => {
expect(personCache.size()).toBe(0)
await personCache.ensurePerson(mockContact)
expect(personCache.size()).toBe(1)
const anotherContact: EmailContact = {
email: 'another@example.com',
firstName: 'Another',
lastName: 'User'
}
await personCache.ensurePerson(anotherContact)
expect(personCache.size()).toBe(2)
})
})
})
@@ -0,0 +1,225 @@
//
// 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, PersonUuid, PersonId, WorkspaceUuid } from '@hcengineering/core'
import { RestClient } from '@hcengineering/api-client'
import { PersonCache, PersonCacheFactory, CachedPerson } from '../person'
import { EmailContact } from '../types'
describe('PersonCacheFactory', () => {
let mockCtx1: jest.Mocked<MeasureContext>
let mockCtx2: jest.Mocked<MeasureContext>
let mockRestClient1: jest.Mocked<RestClient>
let mockRestClient2: jest.Mocked<RestClient>
const workspace1 = 'workspace-1' as WorkspaceUuid
const workspace2 = 'workspace-2' as WorkspaceUuid
const mockContact: EmailContact = {
email: 'test@example.com',
firstName: 'Test',
lastName: 'User'
}
const mockPerson: CachedPerson = {
socialId: 'test-social-id' as PersonId,
uuid: 'test-uuid' as PersonUuid,
localPerson: 'test-local-id'
}
beforeEach(() => {
// Reset all instances before each test
PersonCacheFactory.resetAllInstances()
// Set up mocks
mockCtx1 = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
} as unknown as jest.Mocked<MeasureContext>
mockCtx2 = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
} as unknown as jest.Mocked<MeasureContext>
mockRestClient1 = {
ensurePerson: jest.fn().mockResolvedValue(mockPerson)
} as unknown as jest.Mocked<RestClient>
mockRestClient2 = {
ensurePerson: jest.fn().mockResolvedValue({
...mockPerson,
uuid: 'different-uuid' as PersonUuid
})
} as unknown as jest.Mocked<RestClient>
})
describe('getInstance', () => {
it('should create a new instance for a workspace', () => {
expect(PersonCacheFactory.instanceCount).toBe(0)
const cache = PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace1)
expect(cache).toBeInstanceOf(PersonCache)
expect(PersonCacheFactory.instanceCount).toBe(1)
})
it('should return the same instance for the same workspace', () => {
const cache1 = PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace1)
const cache2 = PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace1)
expect(cache1).toBe(cache2)
expect(PersonCacheFactory.instanceCount).toBe(1)
})
it('should create different instances for different workspaces', () => {
const cache1 = PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace1)
const cache2 = PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace2)
expect(cache1).not.toBe(cache2)
expect(PersonCacheFactory.instanceCount).toBe(2)
})
it('should use the first context and client for subsequent calls with the same workspace', async () => {
// First instance with first context and client
const cache1 = PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace1)
// Try to get instance with different context and client, but same workspace
const cache2 = PersonCacheFactory.getInstance(mockCtx2, mockRestClient2, workspace1)
// Should be the same instance
expect(cache1).toBe(cache2)
// Test that it uses the first client, not the second
await cache2.ensurePerson(mockContact)
expect(mockRestClient1.ensurePerson).toHaveBeenCalled()
expect(mockRestClient2.ensurePerson).not.toHaveBeenCalled()
// Now get a different workspace with second client
const cache3 = PersonCacheFactory.getInstance(mockCtx2, mockRestClient2, workspace2)
await cache3.ensurePerson(mockContact)
// Second client should be used for second workspace
expect(mockRestClient2.ensurePerson).toHaveBeenCalled()
})
})
describe('resetInstance', () => {
it('should remove the instance for a specific workspace', () => {
const cache1 = PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace1)
const cache2 = PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace2)
expect(PersonCacheFactory.instanceCount).toBe(2)
PersonCacheFactory.resetInstance(workspace1)
expect(PersonCacheFactory.instanceCount).toBe(1)
// Getting workspace1 again should create a new instance
const cache1New = PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace1)
expect(cache1New).not.toBe(cache1)
// Workspace2 instance should remain the same
const cache2Again = PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace2)
expect(cache2Again).toBe(cache2)
})
it('should do nothing when resetting a non-existent workspace', () => {
PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace1)
expect(PersonCacheFactory.instanceCount).toBe(1)
// Reset a workspace that doesn't have a cache
PersonCacheFactory.resetInstance('non-existent-workspace' as WorkspaceUuid)
expect(PersonCacheFactory.instanceCount).toBe(1)
})
})
describe('resetAllInstances', () => {
it('should remove all workspace instances', () => {
PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace1)
PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace2)
expect(PersonCacheFactory.instanceCount).toBe(2)
PersonCacheFactory.resetAllInstances()
expect(PersonCacheFactory.instanceCount).toBe(0)
})
})
describe('instanceCount', () => {
it('should return the number of workspace instances', () => {
expect(PersonCacheFactory.instanceCount).toBe(0)
PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace1)
expect(PersonCacheFactory.instanceCount).toBe(1)
PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace2)
expect(PersonCacheFactory.instanceCount).toBe(2)
PersonCacheFactory.resetInstance(workspace1)
expect(PersonCacheFactory.instanceCount).toBe(1)
PersonCacheFactory.resetAllInstances()
expect(PersonCacheFactory.instanceCount).toBe(0)
})
})
describe('integration with PersonCache', () => {
it('should maintain separate caches for each workspace', async () => {
const cache1 = PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace1)
const cache2 = PersonCacheFactory.getInstance(mockCtx2, mockRestClient2, workspace2)
// Populate cache for workspace1
await cache1.ensurePerson(mockContact)
expect(mockRestClient1.ensurePerson).toHaveBeenCalledTimes(1)
mockRestClient1.ensurePerson.mockClear()
// Accessing the same contact in workspace1 should use cache
await cache1.ensurePerson(mockContact)
expect(mockRestClient1.ensurePerson).toHaveBeenCalledTimes(0)
// Accessing the same contact in workspace2 should make a new API call
// since it has a separate cache
await cache2.ensurePerson(mockContact)
expect(mockRestClient2.ensurePerson).toHaveBeenCalledTimes(1)
})
it('should allow clearing cache for a specific workspace', async () => {
const cache1 = PersonCacheFactory.getInstance(mockCtx1, mockRestClient1, workspace1)
const cache2 = PersonCacheFactory.getInstance(mockCtx2, mockRestClient2, workspace2)
// Populate caches
await cache1.ensurePerson(mockContact)
await cache2.ensurePerson(mockContact)
mockRestClient1.ensurePerson.mockClear()
mockRestClient2.ensurePerson.mockClear()
// Clear cache for workspace1
cache1.clearCache()
// Accessing contact in workspace1 should make a new API call
await cache1.ensurePerson(mockContact)
expect(mockRestClient1.ensurePerson).toHaveBeenCalledTimes(1)
// Accessing contact in workspace2 should still use cache
await cache2.ensurePerson(mockContact)
expect(mockRestClient2.ensurePerson).toHaveBeenCalledTimes(0)
})
})
})
@@ -0,0 +1,321 @@
//
// 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,
PersonUuid,
TxOperations,
WorkspaceUuid,
Ref,
Doc,
Space,
toFindResult
} from '@hcengineering/core'
import contact, { PersonSpace } from '@hcengineering/contact'
import { PersonSpacesCache, PersonSpacesCacheFactory } from '../personSpaces'
/* eslint-disable @typescript-eslint/unbound-method */
describe('PersonSpacesCache', () => {
let mockCtx: jest.Mocked<MeasureContext>
let mockClient: jest.Mocked<TxOperations>
let personSpacesCache: PersonSpacesCache
const workspace = 'test-workspace' as WorkspaceUuid
const mailId = 'test-mail-id'
const personUuid = 'test-person-uuid' as PersonUuid
const email = 'test@example.com'
const mockPerson: Doc<Space> = { _id: 'person1' as Ref<any> } as any
const mockPersonSpaces: PersonSpace[] = [
{ _id: 'space1', person: mockPerson._id } as unknown as PersonSpace,
{ _id: 'space2', person: mockPerson._id } as unknown as PersonSpace
]
beforeEach(() => {
jest.clearAllMocks()
mockCtx = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
} as unknown as jest.Mocked<MeasureContext>
mockClient = {
findAll: jest.fn()
} as unknown as jest.Mocked<TxOperations>
mockClient.findAll.mockImplementation((_class, _query, _options) => {
if (_class === contact.class.Person) {
return Promise.resolve(toFindResult([mockPerson]))
}
if (_class === contact.class.PersonSpace) {
return Promise.resolve(toFindResult(mockPersonSpaces))
}
return Promise.resolve(toFindResult([]))
})
personSpacesCache = new PersonSpacesCache(mockCtx, mockClient, workspace)
})
describe('getPersonSpaces', () => {
it('should fetch person spaces from the database on first call', async () => {
const spaces = await personSpacesCache.getPersonSpaces(mailId, personUuid, email)
expect(spaces).toEqual(mockPersonSpaces)
expect(mockClient.findAll).toHaveBeenCalledTimes(2)
expect(mockClient.findAll).toHaveBeenCalledWith(contact.class.Person, { personUuid }, { projection: { _id: 1 } })
expect(mockClient.findAll).toHaveBeenCalledWith(contact.class.PersonSpace, { person: { $in: [mockPerson._id] } })
})
it('should return cached spaces on subsequent calls', async () => {
await personSpacesCache.getPersonSpaces(mailId, personUuid, email)
mockClient.findAll.mockClear()
const spaces = await personSpacesCache.getPersonSpaces(mailId, personUuid, email)
expect(spaces).toEqual(mockPersonSpaces)
expect(mockClient.findAll).not.toHaveBeenCalled()
})
it('should warn when no spaces are found', async () => {
mockClient.findAll.mockImplementation((clazz) => {
if (clazz === contact.class.Person) {
return Promise.resolve(toFindResult([mockPerson]))
}
if (clazz === contact.class.PersonSpace) {
return Promise.resolve(toFindResult([]))
}
return Promise.resolve(toFindResult([]))
})
const spaces = await personSpacesCache.getPersonSpaces(mailId, personUuid, email)
expect(spaces.length).toEqual(0)
expect(mockCtx.warn).toHaveBeenCalledWith('No personal space found, skip', {
mailId,
personUuid,
email,
workspace
})
})
it('should handle errors and remove failed lookups from cache', async () => {
const error = new Error('Database error')
mockClient.findAll.mockRejectedValueOnce(error)
await expect(personSpacesCache.getPersonSpaces(mailId, personUuid, email)).rejects.toThrow(error)
expect(mockCtx.error).toHaveBeenCalledWith('Error fetching person spaces', {
mailId,
personUuid,
email,
workspace,
error: error.message
})
// Verify the cache entry was removed
mockClient.findAll.mockImplementation((clazz) => {
if (clazz === contact.class.Person) {
return Promise.resolve(toFindResult([mockPerson]))
}
if (clazz === contact.class.PersonSpace) {
return Promise.resolve(toFindResult(mockPersonSpaces))
}
return Promise.resolve(toFindResult([]))
})
// Next call should try again
await personSpacesCache.getPersonSpaces(mailId, personUuid, email)
expect(mockClient.findAll).toHaveBeenCalledTimes(3)
})
})
describe('clearPersonCache', () => {
it('should clear the cache for a specific person', async () => {
// Populate cache
await personSpacesCache.getPersonSpaces(mailId, personUuid, email)
mockClient.findAll.mockClear()
// Clear cache for this person
personSpacesCache.clearPersonCache(personUuid)
// Next call should fetch from database again
await personSpacesCache.getPersonSpaces(mailId, personUuid, email)
expect(mockClient.findAll).toHaveBeenCalledTimes(2)
})
})
describe('clearCache', () => {
it('should clear the entire cache', async () => {
// Populate cache
await personSpacesCache.getPersonSpaces(mailId, personUuid, email)
mockClient.findAll.mockClear()
// Clear all cache
personSpacesCache.clearCache()
// Next call should fetch from database again
await personSpacesCache.getPersonSpaces(mailId, personUuid, email)
expect(mockClient.findAll).toHaveBeenCalledTimes(2)
})
})
describe('size', () => {
it('should return the number of cached entries', async () => {
expect(personSpacesCache.size).toBe(0)
await personSpacesCache.getPersonSpaces(mailId, personUuid, email)
expect(personSpacesCache.size).toBe(1)
const anotherPersonUuid = 'another-person-uuid' as PersonUuid
await personSpacesCache.getPersonSpaces(mailId, anotherPersonUuid, 'another@example.com')
expect(personSpacesCache.size).toBe(2)
personSpacesCache.clearPersonCache(personUuid)
expect(personSpacesCache.size).toBe(1)
personSpacesCache.clearCache()
expect(personSpacesCache.size).toBe(0)
})
})
})
describe('PersonSpacesCacheFactory', () => {
let mockCtx1: jest.Mocked<MeasureContext>
let mockCtx2: jest.Mocked<MeasureContext>
let mockClient1: jest.Mocked<TxOperations>
let mockClient2: jest.Mocked<TxOperations>
const workspace1 = 'workspace-1' as WorkspaceUuid
const workspace2 = 'workspace-2' as WorkspaceUuid
beforeEach(() => {
PersonSpacesCacheFactory.resetAllInstances()
mockCtx1 = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
} as unknown as jest.Mocked<MeasureContext>
mockCtx2 = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
} as unknown as jest.Mocked<MeasureContext>
mockClient1 = {
findAll: jest.fn().mockResolvedValue([])
} as unknown as jest.Mocked<TxOperations>
mockClient2 = {
findAll: jest.fn().mockResolvedValue([])
} as unknown as jest.Mocked<TxOperations>
})
describe('getInstance', () => {
it('should create a new instance for a workspace', () => {
expect(PersonSpacesCacheFactory.instanceCount).toBe(0)
const cache = PersonSpacesCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
expect(cache).toBeInstanceOf(PersonSpacesCache)
expect(PersonSpacesCacheFactory.instanceCount).toBe(1)
})
it('should return the same instance for the same workspace', () => {
const cache1 = PersonSpacesCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
const cache2 = PersonSpacesCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
expect(cache1).toBe(cache2)
expect(PersonSpacesCacheFactory.instanceCount).toBe(1)
})
it('should create different instances for different workspaces', () => {
const cache1 = PersonSpacesCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
const cache2 = PersonSpacesCacheFactory.getInstance(mockCtx1, mockClient1, workspace2)
expect(cache1).not.toBe(cache2)
expect(PersonSpacesCacheFactory.instanceCount).toBe(2)
})
it('should use the first context and client for subsequent calls with the same workspace', async () => {
const mailId = 'test-mail-id'
const personUuid = 'test-person-uuid' as PersonUuid
const email = 'test@example.com'
// First instance with first context and client
const cache1 = PersonSpacesCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
// Try to get instance with different context and client, but same workspace
const cache2 = PersonSpacesCacheFactory.getInstance(mockCtx2, mockClient2, workspace1)
// Should be the same instance
expect(cache1).toBe(cache2)
// Test that it uses the first client, not the second
await cache2.getPersonSpaces(mailId, personUuid, email)
expect(mockClient1.findAll).toHaveBeenCalled()
expect(mockClient2.findAll).not.toHaveBeenCalled()
})
})
describe('resetInstance', () => {
it('should remove the instance for a specific workspace', () => {
const cache1 = PersonSpacesCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
PersonSpacesCacheFactory.getInstance(mockCtx1, mockClient1, workspace2)
expect(PersonSpacesCacheFactory.instanceCount).toBe(2)
PersonSpacesCacheFactory.resetInstance(workspace1)
expect(PersonSpacesCacheFactory.instanceCount).toBe(1)
// Getting workspace1 again should create a new instance
const cache1New = PersonSpacesCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
expect(cache1New).not.toBe(cache1)
})
})
describe('resetAllInstances', () => {
it('should remove all workspace instances', () => {
PersonSpacesCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
PersonSpacesCacheFactory.getInstance(mockCtx1, mockClient1, workspace2)
expect(PersonSpacesCacheFactory.instanceCount).toBe(2)
PersonSpacesCacheFactory.resetAllInstances()
expect(PersonSpacesCacheFactory.instanceCount).toBe(0)
})
})
describe('instanceCount', () => {
it('should return the number of workspace instances', () => {
expect(PersonSpacesCacheFactory.instanceCount).toBe(0)
PersonSpacesCacheFactory.getInstance(mockCtx1, mockClient1, workspace1)
expect(PersonSpacesCacheFactory.instanceCount).toBe(1)
PersonSpacesCacheFactory.getInstance(mockCtx1, mockClient1, workspace2)
expect(PersonSpacesCacheFactory.instanceCount).toBe(2)
PersonSpacesCacheFactory.resetInstance(workspace1)
expect(PersonSpacesCacheFactory.instanceCount).toBe(1)
PersonSpacesCacheFactory.resetAllInstances()
expect(PersonSpacesCacheFactory.instanceCount).toBe(0)
})
})
})
+198
View File
@@ -0,0 +1,198 @@
//
// 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,
Ref,
TxOperations,
Doc,
WorkspaceUuid,
generateId,
SocialId
} from '@hcengineering/core'
import chat from '@hcengineering/chat'
import mail from '@hcengineering/mail'
import { PersonSpace } from '@hcengineering/contact'
import { SyncMutex } from './mutex'
const createMutex = new SyncMutex()
/**
* Caches channel references to reduce calls to create mail channels
*/
export class ChannelCache {
// Key is `${spaceId}:${emailAccount}`
private readonly cache = new Map<string, Ref<Doc>>()
constructor (
private readonly ctx: MeasureContext,
private readonly client: TxOperations,
private readonly workspace: WorkspaceUuid
) {}
/**
* Gets or creates a mail channel with caching
*/
async getOrCreateChannel (
spaceId: Ref<PersonSpace>,
participants: PersonId[],
emailAccount: string,
socialId: SocialId
): Promise<Ref<Doc> | undefined> {
const cacheKey = `${spaceId}:${emailAccount}`
let channel = this.cache.get(cacheKey)
if (channel != null) {
return channel
}
channel = await this.fetchOrCreateChannel(spaceId, participants, emailAccount, socialId)
if (channel != null) {
this.cache.set(cacheKey, channel)
}
return channel
}
clearCache (spaceId: Ref<PersonSpace>, emailAccount: string): void {
this.cache.delete(`${spaceId}:${emailAccount}`)
}
clearAllCache (): void {
this.cache.clear()
}
get size (): number {
return this.cache.size
}
private async fetchOrCreateChannel (
space: Ref<PersonSpace>,
participants: PersonId[],
emailAccount: string,
socialId: SocialId
): Promise<Ref<Doc> | undefined> {
try {
// First try to find existing channel
const channel = await this.client.findOne(mail.tag.MailChannel, { title: emailAccount })
if (channel != null) {
this.ctx.info('Using existing channel', { me: emailAccount, space, channel: channel._id })
return channel._id
}
return await this.createNewChannel(space, participants, emailAccount, socialId)
} catch (err) {
this.ctx.error('Failed to create channel', {
me: emailAccount,
space,
workspace: this.workspace,
error: err instanceof Error ? err.message : String(err)
})
// Remove failed lookup from cache
this.cache.delete(`${space}:${emailAccount}`)
return undefined
}
}
private async createNewChannel (
space: Ref<PersonSpace>,
participants: PersonId[],
emailAccount: string,
socialId: SocialId
): Promise<Ref<Doc> | undefined> {
const mutexKey = `channel:${this.workspace}:${space}:${emailAccount}`
const releaseLock = await createMutex.lock(mutexKey)
try {
// Double-check that channel doesn't exist after acquiring lock
const existingChannel = await this.client.findOne(mail.tag.MailChannel, { title: emailAccount })
if (existingChannel != null) {
this.ctx.info('Using existing channel (found after mutex lock)', {
me: emailAccount,
space,
channel: existingChannel._id
})
return existingChannel._id
}
// Create new channel if it doesn't exist
this.ctx.info('Creating new channel', { me: emailAccount, space, personId: socialId._id })
const channelId = await this.client.createDoc(
chat.masterTag.Channel,
space,
{
title: emailAccount,
private: true,
members: participants,
archived: false,
createdBy: socialId._id,
modifiedBy: socialId._id
},
generateId(),
Date.now(),
socialId._id
)
this.ctx.info('Creating mixin', { me: emailAccount, space, personId: socialId._id, channelId })
await this.client.createMixin(
channelId,
chat.masterTag.Channel,
space,
mail.tag.MailChannel,
{},
Date.now(),
socialId._id
)
return channelId
} finally {
releaseLock()
}
}
}
/**
* Factory for creating ChannelCache instances per workspace
*/
export const ChannelCacheFactory = {
instances: new Map<WorkspaceUuid, ChannelCache>(),
getInstance (ctx: MeasureContext, client: TxOperations, workspace: WorkspaceUuid): ChannelCache {
let instance = ChannelCacheFactory.instances.get(workspace)
if (instance === undefined) {
instance = new ChannelCache(ctx, client, workspace)
ChannelCacheFactory.instances.set(workspace, instance)
}
return instance
},
resetInstance (workspace: WorkspaceUuid): void {
ChannelCacheFactory.instances.delete(workspace)
},
resetAllInstances (): void {
ChannelCacheFactory.instances.clear()
},
get instanceCount (): number {
return ChannelCacheFactory.instances.size
}
}
+19
View File
@@ -0,0 +1,19 @@
//
// 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 './message'
export * from './types'
export * from './utils'
export * from './mutex'
+290
View File
@@ -0,0 +1,290 @@
//
// 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 { getClient as getAccountClient, isWorkspaceLoginInfo } from '@hcengineering/account-client'
import { createRestTxOperations, createRestClient } from '@hcengineering/api-client'
import { type Card } from '@hcengineering/card'
import {
type RestClient as CommunicationClient,
createRestClient as getCommunicationClient
} from '@hcengineering/communication-rest-client'
import { MessageType } from '@hcengineering/communication-types'
import chat from '@hcengineering/chat'
import { PersonSpace } from '@hcengineering/contact'
import {
type Blob,
type MeasureContext,
type PersonId,
type Ref,
type TxOperations,
generateId,
PersonUuid,
RateLimiter,
SocialId
} from '@hcengineering/core'
import mail from '@hcengineering/mail'
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
import { BaseConfig, type Attachment } from './types'
import { EmailMessage } from './types'
import { getMdContent } from './utils'
import { PersonCacheFactory } from './person'
import { PersonSpacesCacheFactory } from './personSpaces'
import { ChannelCache, ChannelCacheFactory } from './channel'
export async function createMessages (
config: BaseConfig,
ctx: MeasureContext,
token: string,
message: EmailMessage,
attachments: Attachment[],
me: string,
socialId: SocialId
): Promise<void> {
const { mailId, from, subject, replyTo } = message
const tos = [...(message.to ?? []), ...(message.copy ?? [])]
ctx.info('Sending message', { mailId, from, to: tos.join(',') })
const accountClient = getAccountClient(config.AccountsURL, token)
const wsInfo = await accountClient.getLoginInfoByToken()
if (!isWorkspaceLoginInfo(wsInfo)) {
ctx.error('Unable to get workspace info', { mailId, from, tos })
return
}
const transactorUrl = wsInfo.endpoint.replace('ws://', 'http://').replace('wss://', 'https://')
const txClient = await createRestTxOperations(transactorUrl, wsInfo.workspace, wsInfo.token)
const msgClient = getCommunicationClient(wsInfo.endpoint, wsInfo.workspace, wsInfo.token)
const restClient = createRestClient(transactorUrl, wsInfo.workspace, wsInfo.token)
const personCache = PersonCacheFactory.getInstance(ctx, restClient, wsInfo.workspace)
const personSpacesCache = PersonSpacesCacheFactory.getInstance(ctx, txClient, wsInfo.workspace)
const channelCache = ChannelCacheFactory.getInstance(ctx, txClient, wsInfo.workspace)
const fromPerson = await personCache.ensurePerson(from)
const toPersons: { address: string, uuid: PersonUuid, socialId: PersonId }[] = []
for (const to of tos) {
const toPerson = await personCache.ensurePerson(to)
if (toPerson === undefined) {
continue
}
toPersons.push({ address: to.email, ...toPerson })
}
if (toPersons.length === 0) {
ctx.error('Unable to create message without a proper TO', { mailId, from })
return
}
const modifiedBy = fromPerson.socialId
const participants = [fromPerson.socialId, ...toPersons.map((p) => p.socialId)]
const content = getMdContent(ctx, message)
const attachedBlobs: Attachment[] = []
if (config.StorageConfig !== undefined) {
const storageConfig = storageConfigFromEnv(config.StorageConfig)
const storageAdapter = buildStorageFromConfig(storageConfig)
try {
for (const a of attachments ?? []) {
try {
await storageAdapter.put(
ctx,
{
uuid: wsInfo.workspace,
url: wsInfo.workspaceUrl,
dataId: wsInfo.workspaceDataId
},
a.id,
a.data,
a.contentType
)
attachedBlobs.push(a)
ctx.info('Uploaded attachment', { mailId, blobId: a.id, name: a.name, contentType: a.contentType })
} catch (error) {
ctx.error('Failed to upload attachment', { name: a.name, error, mailId })
}
}
} finally {
await storageAdapter.close()
}
}
try {
const spaces = await personSpacesCache.getPersonSpaces(mailId, fromPerson.uuid, from.email)
if (spaces.length > 0) {
await saveMessageToSpaces(
ctx,
txClient,
msgClient,
mailId,
spaces,
participants,
modifiedBy,
subject,
content,
attachedBlobs,
me,
socialId,
message.sendOn,
channelCache,
replyTo
)
}
} catch (error) {
ctx.error('Failed to save message to personal spaces', {
error,
mailId,
personUuid: fromPerson.uuid,
email: from
})
}
for (const to of toPersons) {
try {
const spaces = await personSpacesCache.getPersonSpaces(mailId, to.uuid, to.address)
if (spaces.length > 0) {
await saveMessageToSpaces(
ctx,
txClient,
msgClient,
mailId,
spaces,
participants,
modifiedBy,
subject,
content,
attachedBlobs,
me,
socialId,
message.sendOn,
channelCache,
replyTo
)
}
} catch (error) {
ctx.error('Failed to save message spaces', { error, mailId, personUuid: to.uuid, email: to.address })
}
}
}
async function saveMessageToSpaces (
ctx: MeasureContext,
client: TxOperations,
msgClient: CommunicationClient,
mailId: string,
spaces: PersonSpace[],
participants: PersonId[],
modifiedBy: PersonId,
subject: string,
content: string,
attachments: Attachment[],
me: string,
socialId: SocialId,
createdDate: number,
channelCache: ChannelCache,
inReplyTo?: string
): Promise<void> {
const rateLimiter = new RateLimiter(10)
for (const space of spaces) {
const spaceId = space._id
await rateLimiter.add(async () => {
ctx.info('Saving message to space', { mailId, space: spaceId })
const route = await client.findOne(mail.class.MailRoute, { mailId, space: spaceId })
if (route !== undefined) {
ctx.info('Message is already in the thread, skip', { mailId, threadId: route.threadId, spaceId })
return
}
let threadId: Ref<Card> | undefined
if (inReplyTo !== undefined) {
const route = await client.findOne(mail.class.MailRoute, { mailId: inReplyTo, space: spaceId })
if (route !== undefined) {
threadId = route.threadId as Ref<Card>
ctx.info('Found existing thread', { mailId, threadId, spaceId })
}
}
if (threadId === undefined) {
const channel = await channelCache.getOrCreateChannel(spaceId, participants, me, socialId)
const newThreadId = await client.createDoc(
chat.masterTag.Thread,
space._id,
{
title: subject,
description: content,
private: true,
members: participants,
archived: false,
createdBy: modifiedBy,
modifiedBy,
parent: channel
},
generateId(),
undefined,
modifiedBy
)
await client.createMixin(
newThreadId,
chat.masterTag.Thread,
space._id,
mail.tag.MailThread,
{},
Date.now(),
socialId._id
)
threadId = newThreadId as Ref<Card>
ctx.info('Created new thread', { mailId, threadId, spaceId })
}
const { id: messageId, created: messageCreated } = await msgClient.createMessage(
threadId,
chat.masterTag.Thread,
content,
modifiedBy,
MessageType.Message,
{
created: createdDate
}
)
ctx.info('Created message', { mailId, messageId, threadId, content })
for (const a of attachments) {
await msgClient.createFile(
threadId,
messageId,
messageCreated,
a.id as Ref<Blob>,
a.contentType,
a.name,
a.data.length,
modifiedBy
)
}
await client.createDoc(
mail.class.MailRoute,
space._id,
{
mailId,
threadId
},
generateId(),
undefined,
modifiedBy
)
})
}
await rateLimiter.waitProcessing()
}
+42
View File
@@ -0,0 +1,42 @@
//
// 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 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()
}
}
}
+122
View File
@@ -0,0 +1,122 @@
//
// 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, PersonUuid, SocialIdType, WorkspaceUuid } from '@hcengineering/core'
import { type RestClient } from '@hcengineering/api-client'
import { EmailContact } from './types'
export interface CachedPerson {
socialId: PersonId
uuid: PersonUuid
localPerson: string
}
/**
* Caches persons to reduce ensure person API calls
*/
export class PersonCache {
private readonly cache = new Map<string, Promise<CachedPerson | undefined>>()
constructor (
private readonly ctx: MeasureContext,
private readonly restClient: RestClient
) {}
/**
* Gets or creates a person by email address with caching
*/
async ensurePerson (contact: EmailContact): Promise<CachedPerson> {
const email = contact.email.toLowerCase().trim()
let personPromise = this.cache.get(email)
if (personPromise === undefined) {
personPromise = this.fetchAndCachePerson(email, contact.firstName, contact.lastName)
this.cache.set(email, personPromise)
}
const result = await personPromise
if (result === undefined) {
throw new Error(`Failed to ensure person exists for email: ${email}`)
}
return result
}
size (): number {
return this.cache.size
}
clearCache (): void {
this.cache.clear()
}
private async fetchAndCachePerson (
email: string,
firstName: string,
lastName: string
): Promise<CachedPerson | undefined> {
try {
const result = await this.restClient.ensurePerson(SocialIdType.EMAIL, email, firstName, lastName)
if (result === undefined) {
this.ctx.warn('Failed to ensure person exists', { email })
return undefined
}
return result
} catch (err) {
this.ctx.error('Error ensuring person exists', {
email,
firstName,
lastName,
error: err instanceof Error ? err.message : String(err)
})
this.cache.delete(email)
throw err
}
}
}
/**
* Factory for creating and managing PersonCache instances per workspace
*/
export const PersonCacheFactory = {
instances: new Map<WorkspaceUuid, PersonCache>(),
getInstance (ctx: MeasureContext, restClient: RestClient, workspace: WorkspaceUuid): PersonCache {
let instance = PersonCacheFactory.instances.get(workspace)
if (instance === undefined) {
instance = new PersonCache(ctx, restClient)
PersonCacheFactory.instances.set(workspace, instance)
}
return instance
},
resetInstance (workspace: WorkspaceUuid): void {
PersonCacheFactory.instances.delete(workspace)
},
resetAllInstances (): void {
PersonCacheFactory.instances.clear()
},
get instanceCount (): number {
return PersonCacheFactory.instances.size
}
}
@@ -0,0 +1,112 @@
//
// 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, PersonUuid, TxOperations, WorkspaceUuid } from '@hcengineering/core'
import contact, { PersonSpace } from '@hcengineering/contact'
/**
* Cache for person spaces
*/
export class PersonSpacesCache {
private readonly cache = new Map<PersonUuid, Promise<PersonSpace[]>>()
constructor (
private readonly ctx: MeasureContext,
private readonly client: TxOperations,
private readonly workspace: WorkspaceUuid
) {}
async getPersonSpaces (mailId: string, personUuid: PersonUuid, email: string): Promise<PersonSpace[]> {
let spacesPromise = this.cache.get(personUuid)
if (spacesPromise === undefined) {
spacesPromise = this.fetchPersonSpaces(mailId, personUuid, email)
this.cache.set(personUuid, spacesPromise)
}
return await spacesPromise
}
clearPersonCache (personUuid: PersonUuid): void {
this.cache.delete(personUuid)
}
clearCache (): void {
this.cache.clear()
}
get size (): number {
return this.cache.size
}
private async fetchPersonSpaces (mailId: string, personUuid: PersonUuid, email: string): Promise<PersonSpace[]> {
try {
const persons = await this.client.findAll(contact.class.Person, { personUuid }, { projection: { _id: 1 } })
const personRefs = persons.map((p) => p._id)
const spaces = await this.client.findAll(contact.class.PersonSpace, { person: { $in: personRefs } })
if (spaces.length === 0) {
this.ctx.warn('No personal space found, skip', { mailId, personUuid, email, workspace: this.workspace })
}
return spaces
} catch (err) {
this.ctx.error('Error fetching person spaces', {
mailId,
personUuid,
email,
workspace: this.workspace,
error: err instanceof Error ? err.message : String(err)
})
// Remove failed lookup from cache to allow retry
this.cache.delete(personUuid)
// Re-throw to allow handling upstream
throw err
}
}
}
/**
* Factory for creating and managing PersonSpacesCache instances per workspace
*/
export const PersonSpacesCacheFactory = {
instances: new Map<WorkspaceUuid, PersonSpacesCache>(),
getInstance (ctx: MeasureContext, client: TxOperations, workspace: WorkspaceUuid): PersonSpacesCache {
let instance = PersonSpacesCacheFactory.instances.get(workspace)
if (instance === undefined) {
instance = new PersonSpacesCache(ctx, client, workspace)
PersonSpacesCacheFactory.instances.set(workspace, instance)
}
return instance
},
resetInstance (workspace: WorkspaceUuid): void {
PersonSpacesCacheFactory.instances.delete(workspace)
},
resetAllInstances (): void {
PersonSpacesCacheFactory.instances.clear()
},
get instanceCount (): number {
return PersonSpacesCacheFactory.instances.size
}
}
+48
View File
@@ -0,0 +1,48 @@
//
// 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 interface Attachment {
id: string
name: string
data: Buffer
contentType: string
size?: number
lastModified: number
}
export interface EmailContact {
email: string
firstName: string
lastName: string
}
export interface EmailMessage {
modifiedOn: number
mailId: string
replyTo?: string
copy?: EmailContact[]
content: string
textContent: string
from: EmailContact
to: EmailContact[]
incoming: boolean
subject: string
sendOn: number
}
export interface BaseConfig {
AccountsURL: string
KvsUrl: string
StorageConfig: string
}
+112
View File
@@ -0,0 +1,112 @@
//
// 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 TurndownService from 'turndown'
import sanitizeHtml from 'sanitize-html'
import { MeasureContext } from '@hcengineering/core'
import { EmailContact, EmailMessage } from './types'
export function getMdContent (ctx: MeasureContext, email: EmailMessage): string {
if (email.content !== undefined) {
try {
const html = sanitizeHtml(email.content)
const tds = new TurndownService()
return tds.turndown(html)
} catch (error) {
ctx.warn('Failed to parse html content', { error })
}
}
return email.textContent
}
/**
* Parse email header into EmailContact objects
* Supports both single and multiple addresses in formats like:
* - "Name" <email@example.com>
* - Name <email@example.com>
* - email@example.com
* - Multiple comma-separated addresses in any of the above formats
*
* @param headerValue Email header value to parse
* @returns Array of EmailContact objects
*/
export function parseEmailHeader (headerValue: string | undefined): EmailContact[] {
if (headerValue == null || headerValue.trim() === '') {
return []
}
// Split the header by commas, but ignore commas inside quotes
const regex = /,(?=(?:[^"]*"[^"]*")*[^"]*$)/
const addresses = headerValue
.split(regex)
.map((addr) => addr.trim())
.filter((addr) => addr !== '')
return addresses.map((address) => parseNameFromEmailHeader(address))
}
export function parseNameFromEmailHeader (headerValue: string | undefined): EmailContact {
if (headerValue == null || headerValue.trim() === '') {
return {
email: '',
firstName: '',
lastName: ''
}
}
// Match pattern like: "Name" <email@example.com> or Name <email@example.com>
const nameEmailPattern = /^(?:"?([^"<]+)"?\s*)?<([^>]+)>$/
const match = headerValue.trim().match(nameEmailPattern)
if (match == null) {
const address = headerValue.trim()
const parts = address.split('@')
return {
email: address,
firstName: parts[0],
lastName: parts[1]
}
}
const displayName = match[1]?.trim()
const email = match[2].trim()
if (displayName == null || displayName === '') {
const parts = email.split('@')
return {
email,
firstName: parts[0],
lastName: parts[1]
}
}
const nameParts = displayName.split(/\s+/)
let firstName: string | undefined
let lastName: string | undefined
if (nameParts.length === 1) {
firstName = nameParts[0]
} else if (nameParts.length > 1) {
firstName = nameParts[0]
lastName = nameParts.slice(1).join(' ')
}
const parts = email.split('@')
return {
email,
firstName: firstName ?? parts[0],
lastName: lastName ?? parts[1]
}
}
+10
View File
@@ -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"
}
}