From 9137ebed2d63c3fa44c7d2d37f09d26bf04d446e Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Thu, 8 May 2025 10:10:20 +0700 Subject: [PATCH] UBERF-10408: Use adapter for message manager Signed-off-by: Artem Savchenko --- common/config/rush/pnpm-lock.yaml | 24 +-- services/gmail/pod-gmail/package.json | 12 +- .../src/__tests__/attachments.test.ts | 22 ++- .../src/__tests__/gmailClient.test.ts | 6 +- .../src/__tests__/message-v1.test.ts | 155 ++++++++++++++++++ .../pod-gmail/src/__tests__/message.test.ts | 151 +---------------- .../pod-gmail/src/__tests__/sync.test.ts | 108 +----------- services/gmail/pod-gmail/src/config.ts | 18 +- services/gmail/pod-gmail/src/gmail.ts | 12 +- .../gmail/pod-gmail/src/message/adapter.ts | 38 +++++ .../pod-gmail/src/message/attachments.ts | 19 ++- services/gmail/pod-gmail/src/message/sync.ts | 34 +--- services/gmail/pod-gmail/src/message/types.ts | 36 ++++ services/gmail/pod-gmail/src/message/utils.ts | 31 ++++ .../pod-gmail/src/message/{ => v1}/message.ts | 11 +- .../gmail/pod-gmail/src/message/v2/message.ts | 103 ++++++++++++ services/gmail/pod-gmail/src/types.ts | 8 - 17 files changed, 444 insertions(+), 344 deletions(-) create mode 100644 services/gmail/pod-gmail/src/__tests__/message-v1.test.ts create mode 100644 services/gmail/pod-gmail/src/message/adapter.ts create mode 100644 services/gmail/pod-gmail/src/message/types.ts create mode 100644 services/gmail/pod-gmail/src/message/utils.ts rename services/gmail/pod-gmail/src/message/{ => v1}/message.ts (96%) create mode 100644 services/gmail/pod-gmail/src/message/v2/message.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 131f726c65..75ee6776c4 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -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)) '@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-gmail-next': 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) @@ -4067,7 +4067,7 @@ packages: version: 0.0.0 '@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 '@rush-temp/chat@file:projects/chat.tgz': @@ -4111,7 +4111,7 @@ packages: version: 0.0.0 '@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 '@rush-temp/communication@file:projects/communication.tgz': @@ -4459,7 +4459,7 @@ packages: version: 0.0.0 '@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 '@rush-temp/model-contact@file:projects/model-contact.tgz': @@ -4863,7 +4863,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': @@ -5547,7 +5547,7 @@ packages: version: 0.0.0 '@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 '@rush-temp/ui@file:projects/ui.tgz': @@ -22341,7 +22341,7 @@ snapshots: - node-notifier - 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: '@tsconfig/node16': 1.0.4 '@types/cors': 2.8.17 @@ -22349,6 +22349,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) @@ -22370,30 +22371,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))': diff --git a/services/gmail/pod-gmail/package.json b/services/gmail/pod-gmail/package.json index 41cc62a0bc..8d0aae843e 100644 --- a/services/gmail/pod-gmail/package.json +++ b/services/gmail/pod-gmail/package.json @@ -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", @@ -36,6 +36,7 @@ "@types/express": "^4.17.13", "@types/node": "~20.11.16", "@types/sanitize-html": "^2.15.0", + "@types/turndown": "^5.0.5", "@typescript-eslint/eslint-plugin": "^6.11.0", "@typescript-eslint/parser": "^6.11.0", "esbuild": "^0.24.2", @@ -58,10 +59,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,9 +84,9 @@ "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" + "sanitize-html": "^2.15.0", + "turndown": "^7.2.0" } } diff --git a/services/gmail/pod-gmail/src/__tests__/attachments.test.ts b/services/gmail/pod-gmail/src/__tests__/attachments.test.ts index def6a84b28..8df3b53aef 100644 --- a/services/gmail/pod-gmail/src/__tests__/attachments.test.ts +++ b/services/gmail/pod-gmail/src/__tests__/attachments.test.ts @@ -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) } diff --git a/services/gmail/pod-gmail/src/__tests__/gmailClient.test.ts b/services/gmail/pod-gmail/src/__tests__/gmailClient.test.ts index a54f0fcca5..48103369b1 100644 --- a/services/gmail/pod-gmail/src/__tests__/gmailClient.test.ts +++ b/services/gmail/pod-gmail/src/__tests__/gmailClient.test.ts @@ -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', () => ({ diff --git a/services/gmail/pod-gmail/src/__tests__/message-v1.test.ts b/services/gmail/pod-gmail/src/__tests__/message-v1.test.ts new file mode 100644 index 0000000000..3701c0503b --- /dev/null +++ b/services/gmail/pod-gmail/src/__tests__/message-v1.test.ts @@ -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 => ({ + config: {}, + data: { + id: 'test-message-id', + internalDate: '1234567890', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'To', value: 'recipient@example.com' }, + { name: 'Message-ID', value: 'test-message-id' }, + { name: 'Subject', value: 'Test Subject' } + ], + parts: [ + { + mimeType: 'text/plain', + body: { data: 'dGVzdCBjb250ZW50' } + }, + { + mimeType: 'text/html', + body: { data: 'PHA+dGVzdCBjb250ZW50PC9wPg==' } + } + ] + } + }, + status: 200, + statusText: 'OK', + headers: {}, + request: { + responseURL: 'https://example.com' + } + }) + + const createMockMessage = (): AttachedData => ({ + messageId: 'test-message-id', + textContent: 'test content', + subject: 'Test Subject', + content: 'test content', + sendOn: 1234567890, + from: 'sender@example.com', + to: 'recipient@example.com', + copy: [], + incoming: true + }) + + it('should save message with attachments', async () => { + const mockMessage = createMockGmailResponse() + const mockChannel = { _id: 'test-channel-id' } as Channel + mockWorkspace.getChannel = jest.fn().mockReturnValue(mockChannel) + mockClient.findOne = jest.fn().mockResolvedValue(undefined) + mockAttachmentHandler.getPartFiles = jest.fn().mockResolvedValue([]) + + await messageManager.saveMessage(mockMessage, 'test@example.com') + + expect(mockClient.tx).toHaveBeenCalled() + expect(mockAttachmentHandler.getPartFiles).toHaveBeenCalled() + }) + + it('should update existing message', async () => { + const mockMessage = createMockGmailResponse() + const mockChannel = { _id: 'test-channel-id' } as Channel + const existingMessage = createMockMessage() + mockWorkspace.getChannel = jest.fn().mockReturnValue(mockChannel) + mockClient.findOne = jest.fn().mockResolvedValue(existingMessage) + mockAttachmentHandler.getPartFiles = jest.fn().mockResolvedValue([]) + + await messageManager.saveMessage(mockMessage, 'test@example.com') + + expect(mockClient.tx).toHaveBeenCalled() + expect(mockAttachmentHandler.getPartFiles).toHaveBeenCalled() + }) + + it('should not save message when no channels found', async () => { + const mockMessage = createMockGmailResponse() + mockWorkspace.getChannel = jest.fn().mockReturnValue(undefined) + + await messageManager.saveMessage(mockMessage, 'test@example.com') + + expect(mockClient.tx).not.toHaveBeenCalled() + expect(mockAttachmentHandler.getPartFiles).not.toHaveBeenCalled() + }) + + it('should handle incoming messages correctly', async () => { + const mockMessage = createMockGmailResponse() + const mockChannel = { _id: 'test-channel-id' } as Channel + mockWorkspace.getChannel = jest.fn().mockReturnValue(mockChannel) + mockClient.findOne = jest.fn().mockResolvedValue(undefined) + mockAttachmentHandler.getPartFiles = jest.fn().mockResolvedValue([]) + + await messageManager.saveMessage(mockMessage, 'recipient@example.com') + + expect(mockWorkspace.getChannel).toHaveBeenCalledWith('sender@example.com') + }) + + it('should handle outgoing messages correctly', async () => { + const mockMessage = createMockGmailResponse() + const mockChannel = { _id: 'test-channel-id' } as Channel + mockWorkspace.getChannel = jest.fn().mockReturnValue(mockChannel) + mockClient.findOne = jest.fn().mockResolvedValue(undefined) + mockAttachmentHandler.getPartFiles = jest.fn().mockResolvedValue([]) + + await messageManager.saveMessage(mockMessage, 'sender@example.com') + + expect(mockWorkspace.getChannel).toHaveBeenCalledWith('recipient@example.com') + }) + }) +}) diff --git a/services/gmail/pod-gmail/src/__tests__/message.test.ts b/services/gmail/pod-gmail/src/__tests__/message.test.ts index 62f7e3b830..b1bae720d4 100644 --- a/services/gmail/pod-gmail/src/__tests__/message.test.ts +++ b/services/gmail/pod-gmail/src/__tests__/message.test.ts @@ -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 => ({ - config: {}, - data: { - id: 'test-message-id', - internalDate: '1234567890', - payload: { - headers: [ - { name: 'From', value: 'sender@example.com' }, - { name: 'To', value: 'recipient@example.com' }, - { name: 'Message-ID', value: 'test-message-id' }, - { name: 'Subject', value: 'Test Subject' } - ], - parts: [ - { - mimeType: 'text/plain', - body: { data: 'dGVzdCBjb250ZW50' } - }, - { - mimeType: 'text/html', - body: { data: 'PHA+dGVzdCBjb250ZW50PC9wPg==' } - } - ] - } - }, - status: 200, - statusText: 'OK', - headers: {}, - request: { - responseURL: 'https://example.com' - } - }) - - const createMockMessage = (): AttachedData => ({ - messageId: 'test-message-id', - textContent: 'test content', - subject: 'Test Subject', - content: 'test content', - sendOn: 1234567890, - from: 'sender@example.com', - to: 'recipient@example.com', - copy: [], - incoming: true - }) - - it('should save message with attachments', async () => { - const mockMessage = createMockGmailResponse() - const mockChannel = { _id: 'test-channel-id' } as Channel - mockWorkspace.getChannel = jest.fn().mockReturnValue(mockChannel) - mockClient.findOne = jest.fn().mockResolvedValue(undefined) - mockAttachmentHandler.getPartFiles = jest.fn().mockResolvedValue([]) - - await messageManager.saveMessage(mockMessage, 'test@example.com') - - expect(mockClient.tx).toHaveBeenCalled() - expect(mockAttachmentHandler.getPartFiles).toHaveBeenCalled() - }) - - it('should update existing message', async () => { - const mockMessage = createMockGmailResponse() - const mockChannel = { _id: 'test-channel-id' } as Channel - const existingMessage = createMockMessage() - mockWorkspace.getChannel = jest.fn().mockReturnValue(mockChannel) - mockClient.findOne = jest.fn().mockResolvedValue(existingMessage) - mockAttachmentHandler.getPartFiles = jest.fn().mockResolvedValue([]) - - await messageManager.saveMessage(mockMessage, 'test@example.com') - - expect(mockClient.tx).toHaveBeenCalled() - expect(mockAttachmentHandler.getPartFiles).toHaveBeenCalled() - }) - - it('should not save message when no channels found', async () => { - const mockMessage = createMockGmailResponse() - mockWorkspace.getChannel = jest.fn().mockReturnValue(undefined) - - await messageManager.saveMessage(mockMessage, 'test@example.com') - - expect(mockClient.tx).not.toHaveBeenCalled() - expect(mockAttachmentHandler.getPartFiles).not.toHaveBeenCalled() - }) - - it('should handle incoming messages correctly', async () => { - const mockMessage = createMockGmailResponse() - const mockChannel = { _id: 'test-channel-id' } as Channel - mockWorkspace.getChannel = jest.fn().mockReturnValue(mockChannel) - mockClient.findOne = jest.fn().mockResolvedValue(undefined) - mockAttachmentHandler.getPartFiles = jest.fn().mockResolvedValue([]) - - await messageManager.saveMessage(mockMessage, 'recipient@example.com') - - expect(mockWorkspace.getChannel).toHaveBeenCalledWith('sender@example.com') - }) - - it('should handle outgoing messages correctly', async () => { - const mockMessage = createMockGmailResponse() - const mockChannel = { _id: 'test-channel-id' } as Channel - mockWorkspace.getChannel = jest.fn().mockReturnValue(mockChannel) - mockClient.findOne = jest.fn().mockResolvedValue(undefined) - mockAttachmentHandler.getPartFiles = jest.fn().mockResolvedValue([]) - - await messageManager.saveMessage(mockMessage, 'sender@example.com') - - expect(mockWorkspace.getChannel).toHaveBeenCalledWith('recipient@example.com') - }) - }) -}) - describe('sanitizeHtml', () => { it('should remove all HTML tags', () => { const html = '

This is bold and italic text

' diff --git a/services/gmail/pod-gmail/src/__tests__/sync.test.ts b/services/gmail/pod-gmail/src/__tests__/sync.test.ts index 81e47709ca..b7672c5c93 100644 --- a/services/gmail/pod-gmail/src/__tests__/sync.test.ts +++ b/services/gmail/pod-gmail/src/__tests__/sync.test.ts @@ -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 + let mockMessageManager: jest.Mocked 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 + } as unknown as jest.Mocked mockGmail = { 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) - }) -}) diff --git a/services/gmail/pod-gmail/src/config.ts b/services/gmail/pod-gmail/src/config.ts index df14ab4134..7d22251c63 100644 --- a/services/gmail/pod-gmail/src/config.ts +++ b/services/gmail/pod-gmail/src/config.ts @@ -13,20 +13,20 @@ // 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' 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: 'v1' | 'v2' } const envMap: { [key in keyof Config]: string } = { @@ -38,12 +38,18 @@ 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 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 = { Port: parseNumber(process.env[envMap.Port]) ?? 8087, AccountsURL: process.env[envMap.AccountsURL], @@ -53,7 +59,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] ?? '

Sent via Huly

', - KvsUrl: process.env[envMap.KvsUrl] + KvsUrl: process.env[envMap.KvsUrl], + StorageConfig: process.env[envMap.StorageConfig], + Version: version } const missingEnv = (Object.keys(params) as Array) diff --git a/services/gmail/pod-gmail/src/gmail.ts b/services/gmail/pod-gmail/src/gmail.ts index e013f547ce..933630aa6f 100644 --- a/services/gmail/pod-gmail/src/gmail.ts +++ b/services/gmail/pod-gmail/src/gmail.ts @@ -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( diff --git a/services/gmail/pod-gmail/src/message/adapter.ts b/services/gmail/pod-gmail/src/message/adapter.ts new file mode 100644 index 0000000000..8aed40a5ac --- /dev/null +++ b/services/gmail/pod-gmail/src/message/adapter.ts @@ -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) + } +} diff --git a/services/gmail/pod-gmail/src/message/attachments.ts b/services/gmail/pod-gmail/src/message/attachments.ts index 6cf3c325f4..0087a7768b 100644 --- a/services/gmail/pod-gmail/src/message/attachments.ts +++ b/services/gmail/pod-gmail/src/message/attachments.ts @@ -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 = { name: file.name, file: id as Ref, - 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() } diff --git a/services/gmail/pod-gmail/src/message/sync.ts b/services/gmail/pod-gmail/src/message/sync.ts index 28f1311fdc..96b9166256 100644 --- a/services/gmail/pod-gmail/src/message/sync.ts +++ b/services/gmail/pod-gmail/src/message/sync.ts @@ -17,9 +17,10 @@ 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' +import { IMessageManager } from './types' interface History { historyId: string @@ -27,41 +28,12 @@ interface History { workspace: string } -export class SyncMutex { - private readonly locks = new Map>() - - async lock (key: string): Promise<() => void> { - // Wait for any existing lock to be released - const currentLock = this.locks.get(key) - if (currentLock != null) { - await currentLock - } - - // Create a new lock - let releaseFn!: () => void - const newLock = new Promise((resolve) => { - releaseFn = resolve - }) - - // Store the lock - this.locks.set(key, newLock) - - // Return the release function - return () => { - if (this.locks.get(key) === newLock) { - this.locks.delete(key) - } - releaseFn() - } - } -} - export class SyncManager { private readonly syncMutex = new SyncMutex() constructor ( private readonly ctx: MeasureContext, - private readonly messageManager: MessageManager, + private readonly messageManager: IMessageManager, private readonly gmail: gmail_v1.Resource$Users, private readonly workspace: string, private readonly keyValueClient: KeyValueClient, diff --git a/services/gmail/pod-gmail/src/message/types.ts b/services/gmail/pod-gmail/src/message/types.ts new file mode 100644 index 0000000000..e1797bdaf7 --- /dev/null +++ b/services/gmail/pod-gmail/src/message/types.ts @@ -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, me: string) => Promise +} diff --git a/services/gmail/pod-gmail/src/message/utils.ts b/services/gmail/pod-gmail/src/message/utils.ts new file mode 100644 index 0000000000..261adffc8c --- /dev/null +++ b/services/gmail/pod-gmail/src/message/utils.ts @@ -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 +} diff --git a/services/gmail/pod-gmail/src/message/message.ts b/services/gmail/pod-gmail/src/message/v1/message.ts similarity index 96% rename from services/gmail/pod-gmail/src/message/message.ts rename to services/gmail/pod-gmail/src/message/v1/message.ts index 87b7e7187a..ff9be48cf1 100644 --- a/services/gmail/pod-gmail/src/message/message.ts +++ b/services/gmail/pod-gmail/src/message/v1/message.ts @@ -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, diff --git a/services/gmail/pod-gmail/src/message/v2/message.ts b/services/gmail/pod-gmail/src/message/v2/message.ts new file mode 100644 index 0000000000..35131688e1 --- /dev/null +++ b/services/gmail/pod-gmail/src/message/v2/message.ts @@ -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, me: string): Promise { + 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, 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() + } +} diff --git a/services/gmail/pod-gmail/src/types.ts b/services/gmail/pod-gmail/src/types.ts index 2e0b3b3184..869b079a96 100644 --- a/services/gmail/pod-gmail/src/types.ts +++ b/services/gmail/pod-gmail/src/types.ts @@ -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 export type RequestType = 'get' | 'post'