diff --git a/services/mail/pod-inbound-mail/src/__tests__/handlerMta.test.ts b/services/mail/pod-inbound-mail/src/__tests__/handlerMta.test.ts index e162b3835a..e6a3047fd6 100644 --- a/services/mail/pod-inbound-mail/src/__tests__/handlerMta.test.ts +++ b/services/mail/pod-inbound-mail/src/__tests__/handlerMta.test.ts @@ -16,10 +16,12 @@ import { Request, Response } from 'express' import { MeasureContext } from '@hcengineering/core' import { createMessages } from '@hcengineering/mail-common' -import { type MtaMessage, handleMtaHook } from '../handlerMta' -import * as client from '../client' import { createRestTxOperations } from '@hcengineering/api-client' +import { handleMtaHook } from '../handlerMta' +import * as client from '../client' +import { type MtaMessage } from '../types' + // Mock dependencies jest.mock('@hcengineering/mail-common', () => ({ createMessages: jest.fn(), @@ -388,4 +390,96 @@ describe('handleMtaHook', () => { } } } + + it('should process HTML email correctly', async () => { + // Mock request with HTML content + const htmlContent = '
This is an HTML test email
' + mockReq = { + headers: { 'x-hook-token': 'test-hook-token' }, + body: createValidMtaMessage('sender@example.com', ['recipient@example.com'], { + subject: 'HTML Test Subject', + contentType: 'text/html; charset=utf-8', + content: htmlContent + }) + } + + await handleMtaHook(mockReq as Request, mockRes as Response, mockCtx) + + // Should return 200 + expect(mockStatus).toHaveBeenCalledWith(200) + expect(mockSend).toHaveBeenCalledWith({ action: 'accept' }) + + // Should process the message with both HTML and text content + expect(createMessages).toHaveBeenCalledWith( + client.baseConfig, + mockCtx, + mockTxOperations, + {}, + {}, + client.mailServiceToken, + mockLoginInfo, + expect.objectContaining({ + mailId: expect.any(String), + from: { email: 'sender@example.com', firstName: 'sender', lastName: 'example.com' }, + to: [{ email: 'recipient@example.com', firstName: 'recipient', lastName: 'example.com' }], + subject: 'HTML Test Subject', + content: htmlContent, + incoming: true + }), + [] // attachments + ) + }) + + it('should process email plain/text content header', async () => { + // Create a multipart email with both text and HTML + const textContent = 'This is the plain text version' + + // Mock message with multipart content by setting multiple headers and contents + const message = { + envelope: { + from: { address: 'sender@example.com' }, + to: [{ address: 'recipient@example.com' }] + }, + message: { + headers: [ + ['Content-Type', 'multipart/alternative; boundary="boundary-string"'], + ['Subject', 'Test Email'], + ['From', 'SenderHTML content
', + attachments: [] + }) + }) + }) + + describe('parseContent', () => { + it('should handle plain text emails', async () => { + // Arrange + const plainTextMessage: MtaMessage = { + envelope: { + from: { address: 'sender@example.com' }, + to: [{ address: 'recipient@example.com' }] + }, + message: { + headers: [['Content-Type', 'text/plain; charset=utf-8']], + contents: 'This is a plain text email' + } + } + + // Act + const result = await parseContent(mockCtx, plainTextMessage) + + // Assert + expect(result).toEqual({ + content: 'This is a plain text email', + attachments: [] + }) + expect(readEml).not.toHaveBeenCalled() + }) + + it('should handle HTML emails', async () => { + // Arrange + const htmlMessage: MtaMessage = { + envelope: { + from: { address: 'sender@example.com' }, + to: [{ address: 'recipient@example.com' }] + }, + message: { + headers: [['Content-Type', 'text/html; charset=utf-8']], + contents: 'This is an HTML email
' + } + } as any + + // Mock readEml to return HTML content + ;(readEml as jest.Mock).mockImplementation((content, callback) => { + callback(null, { + text: '', + html: 'This is an HTML email
', + attachments: [] + }) + }) + + // Act + const result = await parseContent(mockCtx, htmlMessage) + + // Assert + expect(result).toEqual({ + content: 'This is an HTML email', + attachments: [] + }) + }) + + it('should handle multipart emails with text and HTML parts', async () => { + // Arrange + const multipartMessage: MtaMessage = { + envelope: { + from: { address: 'sender@example.com' }, + to: [{ address: 'recipient@example.com' }] + }, + message: { + headers: [['Content-Type', 'multipart/alternative; boundary="boundary"']], + contents: + '--boundary\r\nContent-Type: text/plain\r\n\r\nText part\r\n--boundary\r\nContent-Type: text/html\r\n\r\nHTML part
\r\n--boundary--' + } + } as any + + // Mock readEml to return both text and HTML content + ;(readEml as jest.Mock).mockImplementation((content, callback) => { + callback(null, { + text: 'Text part', + html: 'HTML part
', + attachments: [] + }) + }) + + // Act + const result = await parseContent(mockCtx, multipartMessage) + + // Assert + expect(result).toEqual({ + content: 'HTML part', + attachments: [] + }) + }) + + it('should throw error when Content-Type header is not found', async () => { + // Arrange + const messageWithNoContentType: MtaMessage = { + envelope: { + from: { address: 'sender@example.com' }, + to: [{ address: 'recipient@example.com' }] + }, + message: { + headers: [['Subject', 'Test Email']], + contents: 'Email content' + } + } + + // Act & Assert + await expect(parseContent(mockCtx, messageWithNoContentType)).rejects.toThrow('Content-Type header not found') + }) + }) + + describe('getHeader', () => { + it('should return the value of the specified header', () => { + // Arrange + const message: MtaMessage = { + envelope: { + from: { address: 'sender@example.com' }, + to: [{ address: 'recipient@example.com' }] + }, + message: { + headers: [ + ['Subject', 'Test Email'], + ['Content-Type', 'text/plain'], + ['X-Custom-Header', 'Custom Value'] + ], + contents: 'Email content' + } + } + + // Act & Assert + expect(getHeader(message, 'Subject')).toBe('Test Email') + expect(getHeader(message, 'Content-Type')).toBe('text/plain') + expect(getHeader(message, 'X-Custom-Header')).toBe('Custom Value') + }) + + it('should be case-insensitive when looking for headers', () => { + // Arrange + const message: MtaMessage = { + envelope: { + from: { address: 'sender@example.com' }, + to: [{ address: 'recipient@example.com' }] + }, + message: { + headers: [ + ['Subject', 'Test Email'], + ['Content-Type', 'text/plain'] + ], + contents: 'Email content' + } + } + + // Act & Assert + expect(getHeader(message, 'subject')).toBe('Test Email') + expect(getHeader(message, 'CONTENT-TYPE')).toBe('text/plain') + }) + + it('should return undefined for non-existent headers', () => { + // Arrange + const message: MtaMessage = { + envelope: { + from: { address: 'sender@example.com' }, + to: [{ address: 'recipient@example.com' }] + }, + message: { + headers: [['Subject', 'Test Email']], + contents: 'Email content' + } + } + + // Act & Assert + expect(getHeader(message, 'X-Not-Exists')).toBeUndefined() + }) + + it('should trim the header value', () => { + // Arrange + const message: MtaMessage = { + envelope: { + from: { address: 'sender@example.com' }, + to: [{ address: 'recipient@example.com' }] + }, + message: { + headers: [['Subject', ' Test Email ']], + contents: 'Email content' + } + } + + // Act & Assert + expect(getHeader(message, 'Subject')).toBe('Test Email') + }) + }) + + describe('removeContentTypeHeader', () => { + it('should remove Content-Type header from content', () => { + // Arrange + const content = 'Content-Type: text/plain; charset=utf-8\r\nHello world' + + // Act + const result = removeContentTypeHeader(content) + + // Assert + expect(result).toBe('Hello world') + }) + + it('should handle content with no Content-Type header', () => { + // Arrange + const content = 'Hello world' + + // Act + const result = removeContentTypeHeader(content) + + // Assert + expect(result).toBe('Hello world') + }) + + it('should handle content with Content-Type header in different case', () => { + // Arrange + const content = 'content-type: text/plain; charset=utf-8\r\nHello world' + + // Act + const result = removeContentTypeHeader(content) + + // Assert + expect(result).toBe('Hello world') + }) + + it('should handle content with multiple headers', () => { + // Arrange + const content = + 'Subject: Test Email\r\nContent-Type: text/plain; charset=utf-8\r\nFrom: test@example.com\r\n\r\nHello world' + + // Act + const result = removeContentTypeHeader(content) + + // Assert + expect(result).toBe('Subject: Test Email\r\nFrom: test@example.com\r\n\r\nHello world') + }) + + it('should handle null or undefined content', () => { + // Act & Assert + expect(removeContentTypeHeader(null as any)).toBeNull() + expect(removeContentTypeHeader(undefined as any)).toBeUndefined() + }) + + it('should handle different line endings', () => { + // Arrange + const crlfContent = 'Content-Type: text/plain\r\nHello world' + const lfContent = 'Content-Type: text/plain\nHello world' + const crContent = 'Content-Type: text/plain\rHello world' + + // Act & Assert + expect(removeContentTypeHeader(crlfContent)).toBe('Hello world') + expect(removeContentTypeHeader(lfContent)).toBe('Hello world') + expect(removeContentTypeHeader(crContent)).toBe('Hello world') + }) + }) +}) diff --git a/services/mail/pod-inbound-mail/src/handlerMta.ts b/services/mail/pod-inbound-mail/src/handlerMta.ts index 976a60e687..7c3473f48c 100644 --- a/services/mail/pod-inbound-mail/src/handlerMta.ts +++ b/services/mail/pod-inbound-mail/src/handlerMta.ts @@ -12,44 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import { createHash, randomUUID } from 'crypto' -import { readEml, ReadedEmlJson } from 'eml-parse-js' +import { createHash } from 'crypto' import { Request, Response } from 'express' -import TurndownService from 'turndown' -import sanitizeHtml from 'sanitize-html' import { MeasureContext } from '@hcengineering/core' -import { - type Attachment, - type EmailContact, - type EmailMessage, - createMessages, - getProducer -} from '@hcengineering/mail-common' +import { type EmailContact, type EmailMessage, createMessages, getProducer } from '@hcengineering/mail-common' import { getClient as getAccountClient } from '@hcengineering/account-client' import { createRestTxOperations } from '@hcengineering/api-client' import { mailServiceToken, baseConfig, kvsClient } from './client' import config from './config' - -export interface MtaMessage { - envelope: { - from: { - address: string - } - to: { - address: string - }[] - } - message: { - headers: string[][] - contents: string - } -} - -function getHeader (mta: MtaMessage, header: string): string | undefined { - const h = header.toLowerCase() - return mta.message.headers.find((header) => header[0].toLowerCase() === h)?.[1]?.trim() -} +import { MtaMessage } from './types' +import { getHeader, parseContent } from './utils' export async function handleMtaHook (req: Request, res: Response, ctx: MeasureContext): Promise