mirror of
https://github.com/hcengineering/platform.git
synced 2025-06-09 09:20:54 +00:00
UBERF-10525: Add tests
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
parent
ea5f103f32
commit
b6ca77b155
@ -260,7 +260,7 @@ async function saveMessageToSpaces (
|
|||||||
created: createdDate
|
created: createdDate
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
ctx.info('Created message', { mailId, messageId, threadId, content })
|
ctx.info('Created message', { mailId, messageId, threadId })
|
||||||
|
|
||||||
for (const a of attachments) {
|
for (const a of attachments) {
|
||||||
await msgClient.createFile(
|
await msgClient.createFile(
|
||||||
|
326
services/mail/pod-inbound-mail/src/__tests__/handlerMta.test.ts
Normal file
326
services/mail/pod-inbound-mail/src/__tests__/handlerMta.test.ts
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
//
|
||||||
|
// 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 { 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'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('@hcengineering/mail-common', () => ({
|
||||||
|
createMessages: jest.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('../client', () => ({
|
||||||
|
mailServiceToken: 'mock-token',
|
||||||
|
baseConfig: {
|
||||||
|
AccountsURL: 'http://accounts.test',
|
||||||
|
KvsUrl: 'http://kvs.test',
|
||||||
|
StorageConfig: 'test-storage-config'
|
||||||
|
},
|
||||||
|
kvsClient: {}
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'../config',
|
||||||
|
() => ({
|
||||||
|
hookToken: 'test-hook-token',
|
||||||
|
ignoredAddresses: ['ignored@example.com'],
|
||||||
|
storageConfig: 'test-storage-config'
|
||||||
|
}),
|
||||||
|
{ virtual: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mock Date.now to return consistent timestamp for tests
|
||||||
|
const MOCK_TIMESTAMP = 1620000000000
|
||||||
|
jest.spyOn(Date, 'now').mockImplementation(() => MOCK_TIMESTAMP)
|
||||||
|
|
||||||
|
describe('handleMtaHook', () => {
|
||||||
|
let mockReq: Partial<Request>
|
||||||
|
let mockRes: Partial<Response>
|
||||||
|
let mockCtx: MeasureContext
|
||||||
|
let mockSend: jest.Mock
|
||||||
|
let mockStatus: jest.Mock
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
// Setup mock response
|
||||||
|
mockSend = jest.fn().mockReturnThis()
|
||||||
|
mockStatus = jest.fn().mockReturnValue({ send: mockSend })
|
||||||
|
mockRes = {
|
||||||
|
status: mockStatus,
|
||||||
|
send: mockSend
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup mock context
|
||||||
|
mockCtx = {
|
||||||
|
info: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
warn: jest.fn()
|
||||||
|
} as unknown as MeasureContext
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate hook token correctly', async () => {
|
||||||
|
// Mock request with invalid token
|
||||||
|
mockReq = {
|
||||||
|
headers: { 'x-hook-token': 'invalid-token' },
|
||||||
|
body: createValidMtaMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleMtaHook(mockReq as Request, mockRes as Response, mockCtx)
|
||||||
|
|
||||||
|
// Should still return 200 even with error
|
||||||
|
expect(mockStatus).toHaveBeenCalledWith(200)
|
||||||
|
expect(mockSend).toHaveBeenCalledWith({ action: 'accept' })
|
||||||
|
|
||||||
|
// Should log error
|
||||||
|
expect(mockCtx.error).toHaveBeenCalledWith('mta-hook', {
|
||||||
|
error: expect.any(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should not process the message
|
||||||
|
expect(createMessages).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should skip processing for ignored addresses', async () => {
|
||||||
|
// Mock request with ignored address
|
||||||
|
mockReq = {
|
||||||
|
headers: { 'x-hook-token': 'test-hook-token' },
|
||||||
|
body: createValidMtaMessage('ignored@example.com')
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleMtaHook(mockReq as Request, mockRes as Response, mockCtx)
|
||||||
|
|
||||||
|
// Should return 200
|
||||||
|
expect(mockStatus).toHaveBeenCalledWith(200)
|
||||||
|
expect(mockSend).toHaveBeenCalledWith({ action: 'accept' })
|
||||||
|
|
||||||
|
// Should not process the message
|
||||||
|
expect(createMessages).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should process plain text email correctly', async () => {
|
||||||
|
// Mock request with plain text content
|
||||||
|
mockReq = {
|
||||||
|
headers: { 'x-hook-token': 'test-hook-token' },
|
||||||
|
body: createValidMtaMessage('sender@example.com', ['recipient@example.com'], {
|
||||||
|
subject: 'Test Subject',
|
||||||
|
contentType: 'text/plain; charset=utf-8',
|
||||||
|
content: 'Hello, this is a test email'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(createMessages).toHaveBeenCalledWith(
|
||||||
|
client.baseConfig,
|
||||||
|
mockCtx,
|
||||||
|
client.kvsClient,
|
||||||
|
client.mailServiceToken,
|
||||||
|
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: 'Test Subject',
|
||||||
|
content: 'Hello, this is a test email',
|
||||||
|
incoming: true,
|
||||||
|
modifiedOn: MOCK_TIMESTAMP,
|
||||||
|
sendOn: MOCK_TIMESTAMP
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract names from From/To headers correctly', async () => {
|
||||||
|
// Mock request with named contacts
|
||||||
|
mockReq = {
|
||||||
|
headers: { 'x-hook-token': 'test-hook-token' },
|
||||||
|
body: createValidMtaMessage('sender@example.com', ['recipient@example.com'], {
|
||||||
|
subject: 'Test Subject',
|
||||||
|
contentType: 'text/plain; charset=utf-8',
|
||||||
|
content: 'Hello, this is a test email',
|
||||||
|
additionalHeaders: [
|
||||||
|
['From', 'John Doe <sender@example.com>'],
|
||||||
|
['To', 'Jane Smith <recipient@example.com>']
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleMtaHook(mockReq as Request, mockRes as Response, mockCtx)
|
||||||
|
|
||||||
|
// Should process the message with correct names
|
||||||
|
expect(createMessages).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
from: { email: 'sender@example.com', firstName: 'John', lastName: 'Doe' },
|
||||||
|
to: [{ email: 'recipient@example.com', firstName: 'Jane', lastName: 'Smith' }]
|
||||||
|
}),
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle strip email tags correctly', async () => {
|
||||||
|
// Mock request with tagged email
|
||||||
|
mockReq = {
|
||||||
|
headers: { 'x-hook-token': 'test-hook-token' },
|
||||||
|
body: createValidMtaMessage('sender+tag@example.com', ['recipient+tag@example.com'])
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleMtaHook(mockReq as Request, mockRes as Response, mockCtx)
|
||||||
|
|
||||||
|
// Should process the message with stripped tags
|
||||||
|
expect(createMessages).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
from: { email: 'sender+tag@example.com', firstName: 'sender', lastName: 'example.com' },
|
||||||
|
to: [{ email: 'recipient@example.com', firstName: 'recipient', lastName: 'example.com' }]
|
||||||
|
}),
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use Message-ID when available', async () => {
|
||||||
|
const messageId = '<test-message-id@example.com>'
|
||||||
|
|
||||||
|
// Mock request with Message-ID
|
||||||
|
mockReq = {
|
||||||
|
headers: { 'x-hook-token': 'test-hook-token' },
|
||||||
|
body: createValidMtaMessage('sender@example.com', ['recipient@example.com'], {
|
||||||
|
additionalHeaders: [['Message-ID', messageId]]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleMtaHook(mockReq as Request, mockRes as Response, mockCtx)
|
||||||
|
|
||||||
|
// Should use the provided Message-ID
|
||||||
|
expect(createMessages).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
mailId: messageId
|
||||||
|
}),
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate mailId when Message-ID is missing', async () => {
|
||||||
|
// Mock request without Message-ID
|
||||||
|
mockReq = {
|
||||||
|
headers: { 'x-hook-token': 'test-hook-token' },
|
||||||
|
body: createValidMtaMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleMtaHook(mockReq as Request, mockRes as Response, mockCtx)
|
||||||
|
|
||||||
|
// Should generate an ID
|
||||||
|
expect(createMessages).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
mailId: expect.any(String)
|
||||||
|
}),
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle In-Reply-To header correctly', async () => {
|
||||||
|
const inReplyTo = '<parent-message-id@example.com>'
|
||||||
|
|
||||||
|
// Mock request with In-Reply-To
|
||||||
|
mockReq = {
|
||||||
|
headers: { 'x-hook-token': 'test-hook-token' },
|
||||||
|
body: createValidMtaMessage('sender@example.com', ['recipient@example.com'], {
|
||||||
|
additionalHeaders: [['In-Reply-To', inReplyTo]]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleMtaHook(mockReq as Request, mockRes as Response, mockCtx)
|
||||||
|
|
||||||
|
// Should include the replyTo field
|
||||||
|
expect(createMessages).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
replyTo: inReplyTo
|
||||||
|
}),
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle errors gracefully and return 200', async () => {
|
||||||
|
// Mock createMessages to throw an error
|
||||||
|
;(createMessages as jest.Mock).mockRejectedValueOnce(new Error('Test error'))
|
||||||
|
|
||||||
|
mockReq = {
|
||||||
|
headers: { 'x-hook-token': 'test-hook-token' },
|
||||||
|
body: createValidMtaMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleMtaHook(mockReq as Request, mockRes as Response, mockCtx)
|
||||||
|
|
||||||
|
// Should log the error
|
||||||
|
expect(mockCtx.error).toHaveBeenCalledWith('mta-hook', {
|
||||||
|
error: expect.any(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should still return 200
|
||||||
|
expect(mockStatus).toHaveBeenCalledWith(200)
|
||||||
|
expect(mockSend).toHaveBeenCalledWith({ action: 'accept' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function createValidMtaMessage (
|
||||||
|
fromAddress = 'sender@example.com',
|
||||||
|
toAddresses = ['recipient@example.com'],
|
||||||
|
options: any = {}
|
||||||
|
): MtaMessage {
|
||||||
|
const {
|
||||||
|
subject = 'Test Subject',
|
||||||
|
contentType = 'text/plain; charset=utf-8',
|
||||||
|
content = 'Hello, this is a test email',
|
||||||
|
additionalHeaders = []
|
||||||
|
} = options
|
||||||
|
|
||||||
|
return {
|
||||||
|
envelope: {
|
||||||
|
from: { address: fromAddress },
|
||||||
|
to: toAddresses.map((address) => ({ address }))
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
headers: [['Content-Type', contentType], ['Subject', subject], ...additionalHeaders],
|
||||||
|
contents: content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
@ -23,7 +23,7 @@ import { mailServiceToken, baseConfig, kvsClient } from './client'
|
|||||||
|
|
||||||
import config from './config'
|
import config from './config'
|
||||||
|
|
||||||
interface MtaMessage {
|
export interface MtaMessage {
|
||||||
envelope: {
|
envelope: {
|
||||||
from: {
|
from: {
|
||||||
address: string
|
address: string
|
||||||
@ -54,7 +54,7 @@ export async function handleMtaHook (req: Request, res: Response, ctx: MeasureCo
|
|||||||
|
|
||||||
const mta: MtaMessage = req.body
|
const mta: MtaMessage = req.body
|
||||||
|
|
||||||
const from: EmailContact = { email: mta.envelope.from.address, firstName: '', lastName: '' }
|
const from: EmailContact = getEmailContact(mta.envelope.from.address)
|
||||||
if (config.ignoredAddresses.includes(from.email)) {
|
if (config.ignoredAddresses.includes(from.email)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -65,7 +65,7 @@ export async function handleMtaHook (req: Request, res: Response, ctx: MeasureCo
|
|||||||
from.lastName = lastName
|
from.lastName = lastName
|
||||||
}
|
}
|
||||||
|
|
||||||
const tos: EmailContact[] = mta.envelope.to.map((to) => ({ email: stripTags(to.address), firstName: '', lastName: '' }))
|
const tos: EmailContact[] = mta.envelope.to.map((to) => getEmailContact(stripTags(to.address)))
|
||||||
const toHeader = getHeader(mta, 'To')
|
const toHeader = getHeader(mta, 'To')
|
||||||
if (toHeader !== undefined) {
|
if (toHeader !== undefined) {
|
||||||
for (const part of toHeader.split(',')) {
|
for (const part of toHeader.split(',')) {
|
||||||
@ -188,7 +188,20 @@ async function parseContent (
|
|||||||
return { content, attachments }
|
return { content, attachments }
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractContactName (ctx: MeasureContext, fromHeader: string, email: string): { firstName: string, lastName: string } {
|
function getEmailContact (email: string): EmailContact {
|
||||||
|
const parts = stripTags(email).split('@')
|
||||||
|
return {
|
||||||
|
email,
|
||||||
|
firstName: parts[0],
|
||||||
|
lastName: parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractContactName (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
fromHeader: string,
|
||||||
|
email: string
|
||||||
|
): { firstName: string, lastName: string } {
|
||||||
// Match name part that appears before an email in angle brackets
|
// Match name part that appears before an email in angle brackets
|
||||||
const nameMatch = fromHeader.match(/^\s*"?([^"<]+?)"?\s*<.+?>/)
|
const nameMatch = fromHeader.match(/^\s*"?([^"<]+?)"?\s*<.+?>/)
|
||||||
const encodedName = nameMatch?.[1].trim() ?? ''
|
const encodedName = nameMatch?.[1].trim() ?? ''
|
||||||
|
Loading…
Reference in New Issue
Block a user