UBERF-10408: Use adapter for message manager

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artem Savchenko 2025-05-08 10:10:20 +07:00
parent 60003c8d9e
commit 9137ebed2d
17 changed files with 444 additions and 344 deletions

View File

@ -789,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)) 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': '@rush-temp/pod-gmail':
specifier: file:./projects/pod-gmail.tgz 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-gmail-next': '@rush-temp/pod-gmail-next':
specifier: file:./projects/pod-gmail-next.tgz specifier: file:./projects/pod-gmail-next.tgz
version: file:projects/pod-gmail-next.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) version: file:projects/pod-gmail-next.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)
@ -4067,7 +4067,7 @@ packages:
version: 0.0.0 version: 0.0.0
'@rush-temp/chat-resources@file:projects/chat-resources.tgz': '@rush-temp/chat-resources@file:projects/chat-resources.tgz':
resolution: {integrity: sha512-kP5xHl8xRmxWjmgoSp/TglS3JXlbgsDVLO4quh2Dfz/d1Shzs2dL4g9OptKgJrscMO2OmafNwqIGspjAR/imfw==, tarball: file:projects/chat-resources.tgz} resolution: {integrity: sha512-snQjnHjolp4vMxRX2rxtY6mrmBGVdTQsU/SXtvQxOQYFHCJ12gWHUaAl/61PmRQuKXNZ3OSnulis6kM8BUsOlQ==, tarball: file:projects/chat-resources.tgz}
version: 0.0.0 version: 0.0.0
'@rush-temp/chat@file:projects/chat.tgz': '@rush-temp/chat@file:projects/chat.tgz':
@ -4111,7 +4111,7 @@ packages:
version: 0.0.0 version: 0.0.0
'@rush-temp/communication-resources@file:projects/communication-resources.tgz': '@rush-temp/communication-resources@file:projects/communication-resources.tgz':
resolution: {integrity: sha512-9+OZuK/zjwHSGGBafFODO/vy80mG61lRsTUNHvxcSe/+5SBHJ9jnQP1J7ENaEw3StUebt/4x5fykHaLM5b1J9g==, tarball: file:projects/communication-resources.tgz} resolution: {integrity: sha512-ttk6jX6wYnFbBNRXwQkZ07kU9pE4VFc4gGwfZDOenou7QCSmX6C1Nwz41ZTFLzBKWW6mfEpWJTaCUA0aIIU6NQ==, tarball: file:projects/communication-resources.tgz}
version: 0.0.0 version: 0.0.0
'@rush-temp/communication@file:projects/communication.tgz': '@rush-temp/communication@file:projects/communication.tgz':
@ -4459,7 +4459,7 @@ packages:
version: 0.0.0 version: 0.0.0
'@rush-temp/model-communication@file:projects/model-communication.tgz': '@rush-temp/model-communication@file:projects/model-communication.tgz':
resolution: {integrity: sha512-trMTSOxxb9spxcc/KxQfUFOta6yRbLpOt6tKqPVF2mTaZ9G2/8k8eGcjzTgMEb8ghBk8IQFl5hDkd7LB4vDoyg==, tarball: file:projects/model-communication.tgz} resolution: {integrity: sha512-Fbi79c8xZjCCG892THwn1EHUSA5CecN/DhcyibrsjwWMJP5W4jLnv+2iJhwKHZNZ/bUnRDgKFv5iWUIPu28WDA==, tarball: file:projects/model-communication.tgz}
version: 0.0.0 version: 0.0.0
'@rush-temp/model-contact@file:projects/model-contact.tgz': '@rush-temp/model-contact@file:projects/model-contact.tgz':
@ -4863,7 +4863,7 @@ packages:
version: 0.0.0 version: 0.0.0
'@rush-temp/pod-gmail@file:projects/pod-gmail.tgz': '@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 version: 0.0.0
'@rush-temp/pod-inbound-mail@file:projects/pod-inbound-mail.tgz': '@rush-temp/pod-inbound-mail@file:projects/pod-inbound-mail.tgz':
@ -5547,7 +5547,7 @@ packages:
version: 0.0.0 version: 0.0.0
'@rush-temp/ui-next@file:projects/ui-next.tgz': '@rush-temp/ui-next@file:projects/ui-next.tgz':
resolution: {integrity: sha512-046zP40TN02FxzfJEdU33B0kfek57fc0XwlvWaPn2n/u2UVwihzCt22/a6Q+JjOe11g14CrgqQbHxgb88eRRbA==, tarball: file:projects/ui-next.tgz} resolution: {integrity: sha512-xBYAZFZo/fNykUlBjx2UwSbOZqsh3ilzIsfTsFttS01+WqUIZGRHs1Z9bsSk75sOXqYx86C7il+662gFFj5dSg==, tarball: file:projects/ui-next.tgz}
version: 0.0.0 version: 0.0.0
'@rush-temp/ui@file:projects/ui.tgz': '@rush-temp/ui@file:projects/ui.tgz':
@ -22341,7 +22341,7 @@ snapshots:
- node-notifier - node-notifier
- supports-color - supports-color
'@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: dependencies:
'@tsconfig/node16': 1.0.4 '@tsconfig/node16': 1.0.4
'@types/cors': 2.8.17 '@types/cors': 2.8.17
@ -22349,6 +22349,7 @@ snapshots:
'@types/jest': 29.5.12 '@types/jest': 29.5.12
'@types/node': 20.11.19 '@types/node': 20.11.19
'@types/sanitize-html': 2.15.0 '@types/sanitize-html': 2.15.0
'@types/turndown': 5.0.5
'@types/uuid': 8.3.4 '@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/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) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3)
@ -22370,30 +22371,23 @@ snapshots:
googleapis: 122.0.0(encoding@0.1.13) 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)) 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 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 prettier: 3.2.5
sanitize-html: 2.16.0 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-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: 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) ts-node-dev: 2.0.0(@types/node@20.11.19)(typescript@5.3.3)
turndown: 7.2.0
typescript: 5.3.3 typescript: 5.3.3
uuid: 8.3.2 uuid: 8.3.2
transitivePeerDependencies: transitivePeerDependencies:
- '@aws-sdk/credential-providers'
- '@babel/core' - '@babel/core'
- '@jest/types' - '@jest/types'
- '@mongodb-js/zstd'
- '@swc/core' - '@swc/core'
- '@swc/wasm' - '@swc/wasm'
- babel-jest - babel-jest
- babel-plugin-macros - babel-plugin-macros
- encoding - encoding
- gcp-metadata
- kerberos
- mongodb-client-encryption
- node-notifier - node-notifier
- snappy
- socks
- supports-color - 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))': '@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))':

View File

@ -17,7 +17,7 @@
"_phase:bundle": "rushx bundle", "_phase:bundle": "rushx bundle",
"_phase:docker-build": "rushx docker:build", "_phase:docker-build": "rushx docker:build",
"_phase:docker-staging": "rushx docker:staging", "_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: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: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", "docker:staging": "../../../common/scripts/docker_tag.sh hardcoreeng/gmail staging",
@ -36,6 +36,7 @@
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/node": "~20.11.16", "@types/node": "~20.11.16",
"@types/sanitize-html": "^2.15.0", "@types/sanitize-html": "^2.15.0",
"@types/turndown": "^5.0.5",
"@typescript-eslint/eslint-plugin": "^6.11.0", "@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0", "@typescript-eslint/parser": "^6.11.0",
"esbuild": "^0.24.2", "esbuild": "^0.24.2",
@ -58,10 +59,13 @@
"dependencies": { "dependencies": {
"@hcengineering/attachment": "^0.6.14", "@hcengineering/attachment": "^0.6.14",
"@hcengineering/account-client": "^0.6.0", "@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": "^0.6.18",
"@hcengineering/client-resources": "^0.6.27", "@hcengineering/client-resources": "^0.6.27",
"@hcengineering/contact": "^0.6.24", "@hcengineering/contact": "^0.6.24",
"@hcengineering/mongo": "^0.6.1", "@hcengineering/mail-common": "^0.6.0",
"@hcengineering/core": "^0.6.32", "@hcengineering/core": "^0.6.32",
"@hcengineering/gmail": "^0.6.22", "@hcengineering/gmail": "^0.6.22",
"@hcengineering/platform": "^0.6.11", "@hcengineering/platform": "^0.6.11",
@ -80,9 +84,9 @@
"google-auth-library": "^8.0.2", "google-auth-library": "^8.0.2",
"gaxios": "^5.0.1", "gaxios": "^5.0.1",
"jwt-simple": "^0.5.6", "jwt-simple": "^0.5.6",
"mongodb": "^6.12.0",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"@hcengineering/analytics-service": "^0.6.0", "@hcengineering/analytics-service": "^0.6.0",
"sanitize-html": "^2.15.0" "sanitize-html": "^2.15.0",
"turndown": "^7.2.0"
} }
} }

View File

@ -12,7 +12,7 @@ import {
import { StorageAdapter } from '@hcengineering/server-core' import { StorageAdapter } from '@hcengineering/server-core'
import { gmail_v1 } from 'googleapis' import { gmail_v1 } from 'googleapis'
import { AttachmentHandler } from '../message/attachments' 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 attachment, { Attachment } from '@hcengineering/attachment'
import { decode64, encode64 } from '../base64' import { decode64, encode64 } from '../base64'
@ -83,9 +83,10 @@ describe('AttachmentHandler', () => {
describe('addAttachement', () => { describe('addAttachement', () => {
it('should not add attachment if it already exists', async () => { it('should not add attachment if it already exists', async () => {
const file: AttachedFile = { const file: AttachedFile = {
file: 'test-file', id: 'test-id',
data: Buffer.from('test-file'),
name: 'test.txt', name: 'test.txt',
type: 'text/plain', contentType: 'text/plain',
size: 100, size: 100,
lastModified: Date.now() lastModified: Date.now()
} }
@ -106,9 +107,10 @@ describe('AttachmentHandler', () => {
it('should add new attachment', async () => { it('should add new attachment', async () => {
const file: AttachedFile = { const file: AttachedFile = {
file: 'test-file', id: 'test-id',
data: Buffer.from('test-file'),
name: 'test.txt', name: 'test.txt',
type: 'text/plain', contentType: 'text/plain',
size: 100, size: 100,
lastModified: Date.now() lastModified: Date.now()
} }
@ -154,9 +156,10 @@ describe('AttachmentHandler', () => {
expect(result).toEqual([ expect(result).toEqual([
{ {
file: 'test-data', id: expect.any(String),
data: expect.any(Buffer),
name: 'test.txt', name: 'test.txt',
type: 'text/plain', contentType: 'text/plain',
size: 100, size: 100,
lastModified: expect.any(Number) lastModified: expect.any(Number)
} }
@ -177,9 +180,10 @@ describe('AttachmentHandler', () => {
expect(result).toEqual([ expect(result).toEqual([
{ {
file: 'test-data', id: expect.any(String),
data: expect.any(Buffer),
name: 'test.txt', name: 'test.txt',
type: 'text/plain', contentType: 'text/plain',
size: 100, size: 100,
lastModified: expect.any(Number) lastModified: expect.any(Number)
} }

View File

@ -56,6 +56,10 @@ jest.mock('@hcengineering/core', () => {
} }
}) })
jest.mock('@hcengineering/mail-common', () => ({
createMessages: jest.fn().mockResolvedValue(undefined)
}))
jest.mock('googleapis', () => ({ jest.mock('googleapis', () => ({
gmail_v1: {}, gmail_v1: {},
google: { google: {
@ -69,7 +73,7 @@ jest.mock('googleapis', () => ({
})) }))
jest.mock('../tokens') jest.mock('../tokens')
jest.mock('../message/message') jest.mock('../message/adapter')
jest.mock('../message/sync') jest.mock('../message/sync')
jest.mock('../message/attachments') jest.mock('../message/attachments')
jest.mock('@hcengineering/server-client', () => ({ jest.mock('@hcengineering/server-client', () => ({

View File

@ -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')
})
})
})

View File

@ -1,159 +1,10 @@
import { type MeasureContext, PersonId, TxOperations, AttachedData } from '@hcengineering/core' import { sanitizeText } from '../message/v2/message'
import { type GaxiosResponse } from 'gaxios'
import { gmail_v1 } from 'googleapis'
import { type Message } from '@hcengineering/gmail'
import { type Channel } from '../types'
import { AttachmentHandler } from '../message/attachments'
import { MessageManager, sanitizeText } from '../message/message'
/* eslint-disable @typescript-eslint/consistent-type-assertions */ /* eslint-disable @typescript-eslint/consistent-type-assertions */
/* eslint-disable @typescript-eslint/unbound-method */ /* eslint-disable @typescript-eslint/unbound-method */
jest.mock('../config') 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', () => { describe('sanitizeHtml', () => {
it('should remove all HTML tags', () => { it('should remove all HTML tags', () => {
const html = '<p>This is <b>bold</b> and <i>italic</i> text</p>' const html = '<p>This is <b>bold</b> and <i>italic</i> text</p>'

View File

@ -13,12 +13,12 @@
// limitations under the License. // limitations under the License.
// //
import { MeasureContext, PersonId } from '@hcengineering/core' import { MeasureContext, PersonId } from '@hcengineering/core'
import { SyncManager, SyncMutex } from '../message/sync' import { SyncManager } from '../message/sync'
import { MessageManager } from '../message/message' import { MessageManagerV2 } from '../message/v2/message'
import { RateLimiter } from '../rateLimiter' import { RateLimiter } from '../rateLimiter'
jest.mock('../config') jest.mock('../config')
jest.mock('../message/message') jest.mock('../message/adapter')
jest.mock('../utils', () => { jest.mock('../utils', () => {
const originalModule = jest.requireActual('../utils') const originalModule = jest.requireActual('../utils')
return { return {
@ -46,7 +46,7 @@ const mockKeyValueClientInstance = {
describe('SyncManager', () => { describe('SyncManager', () => {
// Mocked dependencies // Mocked dependencies
let mockCtx: MeasureContext let mockCtx: MeasureContext
let mockMessageManager: jest.Mocked<MessageManager> let mockMessageManager: jest.Mocked<MessageManagerV2>
let mockGmail: any // Using any for easier mocking let mockGmail: any // Using any for easier mocking
let mockKeyValueClient: MockKeyValueClient let mockKeyValueClient: MockKeyValueClient
@ -68,7 +68,7 @@ describe('SyncManager', () => {
mockMessageManager = { mockMessageManager = {
saveMessage: jest.fn().mockResolvedValue(undefined) saveMessage: jest.fn().mockResolvedValue(undefined)
} as unknown as jest.Mocked<MessageManager> } as unknown as jest.Mocked<MessageManagerV2>
mockGmail = { mockGmail = {
history: { history: {
@ -237,101 +237,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)
})
})

View File

@ -13,20 +13,20 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
// //
import { BaseConfig } from '@hcengineering/mail-common'
import { config as dotenvConfig } from 'dotenv' import { config as dotenvConfig } from 'dotenv'
dotenvConfig() dotenvConfig()
interface Config { interface Config extends BaseConfig {
Port: number Port: number
AccountsURL: string
ServiceID: string ServiceID: string
Secret: string Secret: string
Credentials: string Credentials: string
WATCH_TOPIC_NAME: string WATCH_TOPIC_NAME: string
FooterMessage: string FooterMessage: string
InitLimit: number InitLimit: number
KvsUrl: string Version: 'v1' | 'v2'
} }
const envMap: { [key in keyof Config]: string } = { const envMap: { [key in keyof Config]: string } = {
@ -38,12 +38,18 @@ const envMap: { [key in keyof Config]: string } = {
WATCH_TOPIC_NAME: 'WATCH_TOPIC_NAME', WATCH_TOPIC_NAME: 'WATCH_TOPIC_NAME',
FooterMessage: 'FOOTER_MESSAGE', FooterMessage: 'FOOTER_MESSAGE',
InitLimit: 'INIT_LIMIT', 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 parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined)
const config: Config = (() => { const config: Config = (() => {
const version = process.env[envMap.Version] ?? 'v1'
if (version !== 'v1' && version !== 'v2') {
throw new Error(`Invalid version: ${version}. Must be 'v1' or 'v2'.`)
}
const params: Partial<Config> = { const params: Partial<Config> = {
Port: parseNumber(process.env[envMap.Port]) ?? 8087, Port: parseNumber(process.env[envMap.Port]) ?? 8087,
AccountsURL: process.env[envMap.AccountsURL], AccountsURL: process.env[envMap.AccountsURL],
@ -53,7 +59,9 @@ const config: Config = (() => {
WATCH_TOPIC_NAME: process.env[envMap.WATCH_TOPIC_NAME], WATCH_TOPIC_NAME: process.env[envMap.WATCH_TOPIC_NAME],
InitLimit: parseNumber(process.env[envMap.InitLimit]) ?? 50, InitLimit: parseNumber(process.env[envMap.InitLimit]) ?? 50,
FooterMessage: process.env[envMap.FooterMessage] ?? '<br><br><p>Sent via <a href="https://huly.io">Huly</a></p>', FooterMessage: process.env[envMap.FooterMessage] ?? '<br><br><p>Sent via <a href="https://huly.io">Huly</a></p>',
KvsUrl: process.env[envMap.KvsUrl] KvsUrl: process.env[envMap.KvsUrl],
StorageConfig: process.env[envMap.StorageConfig],
Version: version
} }
const missingEnv = (Object.keys(params) as Array<keyof Config>) const missingEnv = (Object.keys(params) as Array<keyof Config>)

View File

@ -30,10 +30,11 @@ import { getOrCreateSocialId } from './accounts'
import { createIntegrationIfNotEsixts, disableIntegration, removeIntegration } from './integrations' import { createIntegrationIfNotEsixts, disableIntegration, removeIntegration } from './integrations'
import { AttachmentHandler } from './message/attachments' import { AttachmentHandler } from './message/attachments'
import { TokenStorage } from './tokens' import { TokenStorage } from './tokens'
import { MessageManager } from './message/message' import { createMessageManager } from './message/adapter'
import { SyncManager } from './message/sync' import { SyncManager } from './message/sync'
import { getEmail } from './gmail/utils' import { getEmail } from './gmail/utils'
import { Integration } from '@hcengineering/account-client' import { Integration } from '@hcengineering/account-client'
import { IMessageManager } from './message/types'
const SCOPES = ['https://www.googleapis.com/auth/gmail.modify'] const SCOPES = ['https://www.googleapis.com/auth/gmail.modify']
@ -81,7 +82,7 @@ export class GmailClient {
private refreshTimer: NodeJS.Timeout | undefined = undefined private refreshTimer: NodeJS.Timeout | undefined = undefined
private readonly rateLimiter = new RateLimiter(1000, 200) private readonly rateLimiter = new RateLimiter(1000, 200)
private readonly attachmentHandler: AttachmentHandler private readonly attachmentHandler: AttachmentHandler
private readonly messageManager: MessageManager private readonly messageManager: IMessageManager
private readonly syncManager: SyncManager private readonly syncManager: SyncManager
private readonly integrationToken: string private readonly integrationToken: string
private integration: Integration | undefined = undefined private integration: Integration | undefined = undefined
@ -104,12 +105,13 @@ export class GmailClient {
this.client = new TxOperations(client, this.socialId._id) this.client = new TxOperations(client, this.socialId._id)
this.account = this.user.userId this.account = this.user.userId
this.attachmentHandler = new AttachmentHandler(ctx, workspaceId, storageAdapter, this.gmail, this.client) this.attachmentHandler = new AttachmentHandler(ctx, workspaceId, storageAdapter, this.gmail, this.client)
this.messageManager = new MessageManager( this.messageManager = createMessageManager(
ctx, ctx,
this.client, this.client,
this.attachmentHandler, this.attachmentHandler,
this.socialId._id, this.workspace,
this.workspace this.integrationToken,
socialId
) )
const keyValueClient = getKvsClient(this.integrationToken) const keyValueClient = getKvsClient(this.integrationToken)
this.syncManager = new SyncManager( this.syncManager = new SyncManager(

View File

@ -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)
}
}

View File

@ -12,13 +12,14 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
// //
import { randomUUID } from 'crypto'
import attachment, { Attachment } from '@hcengineering/attachment' import attachment, { Attachment } from '@hcengineering/attachment'
import { AttachedData, Blob, MeasureContext, Ref, TxOperations, WorkspaceUuid } from '@hcengineering/core' import { AttachedData, Blob, MeasureContext, Ref, TxOperations, WorkspaceUuid } from '@hcengineering/core'
import { StorageAdapter } from '@hcengineering/server-core' import { StorageAdapter } from '@hcengineering/server-core'
import { gmail_v1 } from 'googleapis' import { gmail_v1 } from 'googleapis'
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
import { encode64 } from '../base64' import { encode64 } from '../base64'
import type { AttachedFile } from '../types' import type { AttachedFile } from './types'
import { addFooter } from '../utils' import { addFooter } from '../utils'
export class AttachmentHandler { export class AttachmentHandler {
@ -39,11 +40,11 @@ export class AttachmentHandler {
const data: AttachedData<Attachment> = { const data: AttachedData<Attachment> = {
name: file.name, name: file.name,
file: id as Ref<Blob>, file: id as Ref<Blob>,
type: file.type ?? 'undefined', type: file.data.toString('base64') ?? 'undefined',
size: file.size ?? Buffer.from(file.file, 'base64').length, size: file.size ?? file.data.length,
lastModified: file.lastModified 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( await this.client.addCollection(
attachment.class.Attachment, attachment.class.Attachment,
message.space, message.space,
@ -65,9 +66,10 @@ export class AttachmentHandler {
if (attachment.data == null) return [] if (attachment.data == null) return []
return [ return [
{ {
file: attachment.data, id: randomUUID(),
name: part.filename, name: part.filename,
type: part.mimeType ?? undefined, data: Buffer.from(attachment.data, 'base64'),
contentType: part.mimeType ?? 'application/octet-stream',
size: attachment.size ?? undefined, size: attachment.size ?? undefined,
lastModified: new Date().getTime() lastModified: new Date().getTime()
} }
@ -76,9 +78,10 @@ export class AttachmentHandler {
if (part.body?.data == null) return [] if (part.body?.data == null) return []
return [ return [
{ {
file: part.body.data, id: randomUUID(),
data: Buffer.from(part.body.data, 'base64'),
name: part.filename, name: part.filename,
type: part.mimeType ?? undefined, contentType: part.mimeType ?? 'application/octet-stream',
size: part.body.size ?? undefined, size: part.body.size ?? undefined,
lastModified: new Date().getTime() lastModified: new Date().getTime()
} }

View File

@ -17,9 +17,10 @@ import { gmail_v1 } from 'googleapis'
import { type MeasureContext, PersonId } from '@hcengineering/core' import { type MeasureContext, PersonId } from '@hcengineering/core'
import { type KeyValueClient } from '@hcengineering/kvs-client' import { type KeyValueClient } from '@hcengineering/kvs-client'
import { SyncMutex } from '@hcengineering/mail-common'
import { RateLimiter } from '../rateLimiter' import { RateLimiter } from '../rateLimiter'
import { MessageManager } from './message' import { IMessageManager } from './types'
interface History { interface History {
historyId: string historyId: string
@ -27,41 +28,12 @@ interface History {
workspace: string workspace: string
} }
export class SyncMutex {
private readonly locks = new Map<string, Promise<void>>()
async lock (key: string): Promise<() => void> {
// Wait for any existing lock to be released
const currentLock = this.locks.get(key)
if (currentLock != null) {
await currentLock
}
// Create a new lock
let releaseFn!: () => void
const newLock = new Promise<void>((resolve) => {
releaseFn = resolve
})
// Store the lock
this.locks.set(key, newLock)
// Return the release function
return () => {
if (this.locks.get(key) === newLock) {
this.locks.delete(key)
}
releaseFn()
}
}
}
export class SyncManager { export class SyncManager {
private readonly syncMutex = new SyncMutex() private readonly syncMutex = new SyncMutex()
constructor ( constructor (
private readonly ctx: MeasureContext, private readonly ctx: MeasureContext,
private readonly messageManager: MessageManager, private readonly messageManager: IMessageManager,
private readonly gmail: gmail_v1.Resource$Users, private readonly gmail: gmail_v1.Resource$Users,
private readonly workspace: string, private readonly workspace: string,
private readonly keyValueClient: KeyValueClient, private readonly keyValueClient: KeyValueClient,

View File

@ -0,0 +1,36 @@
//
// 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 IMessageManager {
saveMessage: (message: GaxiosResponse<gmail_v1.Schema$Message>, me: string) => Promise<void>
}

View File

@ -0,0 +1,31 @@
//
// 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 { EmailMessage } from '@hcengineering/mail-common'
import { MeasureContext } from '@hcengineering/core'
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
}

View File

@ -32,15 +32,16 @@ import core from '@hcengineering/core'
import attachment, { Attachment } from '@hcengineering/attachment' import attachment, { Attachment } from '@hcengineering/attachment'
import sanitizeHtml from 'sanitize-html' import sanitizeHtml from 'sanitize-html'
import { type Channel } from '../types' import { IMessageManager } from '../types'
import { AttachmentHandler } from './attachments' import { type Channel } from '../../types'
import { decode64 } from '../base64' import { AttachmentHandler } from '../attachments'
import { diffAttributes } from '../utils' import { decode64 } from '../../base64'
import { diffAttributes } from '../../utils'
const EMAIL_REGEX = 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,}))/ /(([^<>()[\]\\.,;:\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 ( constructor (
private readonly ctx: MeasureContext, private readonly ctx: MeasureContext,
private readonly client: TxOperations, private readonly client: TxOperations,

View File

@ -0,0 +1,103 @@
//
// 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, 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 = parseNameFromEmailHeader(getHeaderValue(message.data.payload, 'To') ?? '')
const copy =
getHeaderValue(message.data.payload, 'Cc')
?.split(',')
.map((p) => parseNameFromEmailHeader(p.trim())) ?? undefined
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()
}
}

View File

@ -37,14 +37,6 @@ export type State = User & {
redirectURL: string 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 Channel = Pick<PlatformChannel, 'value' | keyof Doc>
export type RequestType = 'get' | 'post' export type RequestType = 'get' | 'post'