UBERF-11239: Fix multipart content in mta-hook (#9110)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko 2025-05-27 12:04:12 +07:00 committed by GitHub
parent 86601457da
commit 26cbba64f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 147 additions and 15 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -394,6 +394,10 @@ describe('handleMtaHook', () => {
it('should process HTML email correctly', async () => {
// Mock request with HTML content
const htmlContent = '<html><body><h1>Hello</h1><p>This is an <b>HTML</b> test email</p></body></html>'
const expectedContent = `Hello
=====
This is an **HTML** test email`
mockReq = {
headers: { 'x-hook-token': 'test-hook-token' },
body: createValidMtaMessage('sender@example.com', ['recipient@example.com'], {
@ -423,7 +427,7 @@ describe('handleMtaHook', () => {
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,
content: expectedContent,
incoming: true
}),
[] // attachments

View File

@ -0,0 +1,115 @@
//
// 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 fs from 'fs/promises'
import path from 'path'
import { MeasureContext } from '@hcengineering/core'
import { parseContent } from '../utils'
import { type MtaMessage } from '../types'
// Mock config to ensure storage is available for tests
jest.mock('../config', () => ({
storageConfig: {
// Mock minimal storage config to ensure attachments are processed
type: 'fs',
url: '/tmp'
}
}))
// Create a mock logger context
const mockContext = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
} as unknown as MeasureContext
describe('parseContent', () => {
beforeEach(() => {
jest.clearAllMocks()
})
test('should parse email with attachment', async () => {
const attachmentEml = await fs.readFile(path.join(__dirname, '__mocks__/attachment.txt'), 'utf-8')
// Create MTA message from the sample email file
const mtaMessage: MtaMessage = {
envelope: {
from: { address: 'test1@example.com' },
to: [{ address: 'test2@example.com' }]
},
message: {
headers: [['Content-Type', 'multipart/mixed; boundary="000000000000384e1705e9d0930b"']],
contents: attachmentEml
}
}
const result = await parseContent(mockContext, mtaMessage)
// Verify content was parsed
expect(result.content).toContain('Attached huly.png')
// Verify attachment was extracted
expect(result.attachments.length).toBe(1)
// Check attachment properties
const attachment = result.attachments[0]
expect(attachment).toHaveProperty('id')
expect(attachment).toHaveProperty('name')
expect(attachment).toHaveProperty('data')
expect(attachment).toHaveProperty('contentType')
// Verify attachment data is a Buffer
expect(Buffer.isBuffer(attachment.data)).toBe(true)
expect(attachment.name).toBe('huly.png')
})
test('should parse email with 2 attachments', async () => {
const attachmentEml = await fs.readFile(path.join(__dirname, '__mocks__/2attachments.txt'), 'utf-8')
// Create MTA message from the sample email file
const mtaMessage: MtaMessage = {
envelope: {
from: { address: 'test1@example.com' },
to: [{ address: 'test2@example.com' }]
},
message: {
headers: [['Content-Type', 'multipart/mixed; boundary="00000000000016290e0636150598"']],
contents: attachmentEml
}
}
const result = await parseContent(mockContext, mtaMessage)
// Verify content was parsed
expect(result.content).toContain('Send huly.png and cat.png')
// Verify attachment was extracted
expect(result.attachments.length).toBe(2)
// Check attachment properties
const attachment = result.attachments[0]
expect(attachment).toHaveProperty('id')
expect(attachment).toHaveProperty('name')
expect(attachment).toHaveProperty('data')
expect(attachment).toHaveProperty('contentType')
// Verify attachment data is a Buffer
expect(Buffer.isBuffer(attachment.data)).toBe(true)
expect(attachment.name).toBe('huly.png')
const catAttachment = result.attachments[1]
// Verify attachment data is a Buffer
expect(Buffer.isBuffer(catAttachment.data)).toBe(true)
expect(catAttachment.name).toBe('cat.png')
})
})

View File

@ -27,7 +27,7 @@ export async function parseContent (
mta: MtaMessage
): Promise<{ content: string, attachments: Attachment[] }> {
// TODO: UBERF-11029 - remove this logging after testing
ctx.info('Parsing email content', { content: mta.message.contents })
ctx.info('Parsing email content', { mta })
const contentType = getHeader(mta, 'Content-Type')
if (contentType === undefined) {
throw new Error('Content-Type header not found')
@ -37,7 +37,7 @@ export async function parseContent (
return { content: mta.message.contents, attachments: [] }
}
const email = await getEmailContent(mta.message.contents, contentType)
const email = await getEmailContent(mta)
let content = email.text ?? ''
let isMarkdown = false
@ -83,23 +83,34 @@ export async function parseContent (
return { content, attachments }
}
export function convertMtaToEml (mta: MtaMessage): string {
return `MIME-Version: 1.0
Date: ${new Date().toUTCString()}
From: ${mta.envelope.from.address}
To: ${mta.envelope.to.map((to) => to.address).join(', ')}
Content-Type: ${getHeader(mta, 'Content-Type') ?? 'text/plain; charset=utf-8'}
${unescapeString(mta.message.contents)}`
}
function unescapeString (str: string): string {
return str
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
}
export function getHeader (mta: MtaMessage, header: string): string | undefined {
const h = header.toLowerCase()
return mta.message.headers.find((header) => header[0].toLowerCase() === h)?.[1]?.trim()
}
async function getEmailContent (mtaContent: string, contentType: string): Promise<ReadedEmlJson> {
if (mtaContent == null) {
return {
text: '',
html: '',
attachments: []
} as any
}
const contentRegex = /Content-Type/i
const content = contentRegex.test(mtaContent) ? mtaContent : `Content-Type: ${contentType}\r\n${mtaContent}`
async function getEmailContent (mta: MtaMessage): Promise<ReadedEmlJson> {
const eml = convertMtaToEml(mta)
const email = await new Promise<ReadedEmlJson>((resolve, reject) => {
readEml(content, (err, json) => {
readEml(eml, (err, json) => {
if (err !== undefined && err !== null) {
reject(new Error(`Email parsing error: ${err.message}`))
} else if (json === undefined) {
@ -112,7 +123,7 @@ async function getEmailContent (mtaContent: string, contentType: string): Promis
if (isEmptyString(email.text) && isEmptyString(email.html)) {
return {
...email,
text: removeContentTypeHeader(mtaContent)
text: removeContentTypeHeader(mta.message.contents)
}
}
return email