feat(history): complete messaging core before multi-account gateways
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js'
|
||||
import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js'
|
||||
|
||||
test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => {
|
||||
assert.deepEqual(
|
||||
@@ -42,3 +43,12 @@ test('业务指标工具处理百分比、分组和无效日期', () => {
|
||||
{ new: 2, won: 1 },
|
||||
)
|
||||
})
|
||||
|
||||
test('Email 渠道凭据使用认证加密且可正确解密', () => {
|
||||
process.env.EMAIL_CREDENTIAL_KEY = 'lingxi-email-test-key-with-more-than-32-bytes'
|
||||
const encrypted = encryptEmailPassword('app-password-123')
|
||||
assert.notEqual(encrypted, 'app-password-123')
|
||||
assert.match(encrypted, /^v1:/)
|
||||
assert.equal(decryptEmailPassword(encrypted), 'app-password-123')
|
||||
assert.throws(() => decryptEmailPassword(`${encrypted.slice(0, -1)}x`))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
encryptEmailPassword,
|
||||
fetchEmailMessages,
|
||||
sendEmailMessage,
|
||||
} from '../src/email.js'
|
||||
|
||||
const enabled = process.env.RUN_EMAIL_API_E2E === '1'
|
||||
const baseUrl = process.env.EMAIL_API_E2E_BASE_URL || 'http://127.0.0.1:8088'
|
||||
|
||||
function localChannel(account) {
|
||||
return {
|
||||
account,
|
||||
encryptedPassword: encryptEmailPassword('pwd'),
|
||||
imapHost: '127.0.0.1',
|
||||
imapPort: 3143,
|
||||
imapSecure: false,
|
||||
imapFolder: 'INBOX',
|
||||
smtpHost: '127.0.0.1',
|
||||
smtpPort: 3025,
|
||||
smtpSecure: false,
|
||||
syncDays: 7,
|
||||
}
|
||||
}
|
||||
|
||||
function unwrap(payload) {
|
||||
return payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload
|
||||
}
|
||||
|
||||
async function request(path, { token, method = 'GET', body } = {}) {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const text = await response.text()
|
||||
const payload = text ? JSON.parse(text) : null
|
||||
if (!response.ok) throw new Error(`${method} ${path} -> ${response.status}: ${payload?.message || text}`)
|
||||
return unwrap(payload)
|
||||
}
|
||||
|
||||
async function waitForSenderMessage(predicate) {
|
||||
const sender = localChannel('sender@local.test')
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const result = await fetchEmailMessages(sender, { full: true })
|
||||
const message = result.messages.find(predicate)
|
||||
if (message) return message
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
}
|
||||
throw new Error('等待网关发出的邮件进入 sender IMAP 超时')
|
||||
}
|
||||
|
||||
test('真实 HTTP 网关完成 Email 连接、全量同步、附件读取、回复与已读', { skip: !enabled }, async () => {
|
||||
process.env.JWT_SECRET ||= 'lingxi-email-api-e2e-secret'
|
||||
const suffix = Date.now()
|
||||
const senderChannel = localChannel('sender@local.test')
|
||||
const olderSubject = `Lingxi sorting older ${suffix}`
|
||||
const newerSubject = `Lingxi sorting newer ${suffix}`
|
||||
await sendEmailMessage(senderChannel, {
|
||||
to: 'receiver@local.test',
|
||||
subject: olderSubject,
|
||||
text: 'Older conversation used for stable ordering',
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 30))
|
||||
await sendEmailMessage(senderChannel, {
|
||||
to: 'receiver@local.test',
|
||||
subject: newerSubject,
|
||||
text: 'Newer conversation must remain above the opened older conversation',
|
||||
})
|
||||
|
||||
const session = await request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Email API E2E',
|
||||
account: `email-api-${suffix}@local.test`,
|
||||
password: 'Lingxi-Email-Api-2026!',
|
||||
company: 'Lingxi Protocol Lab',
|
||||
industry: '跨境电商',
|
||||
teamSize: '1-10人',
|
||||
},
|
||||
})
|
||||
assert.ok(session.token)
|
||||
|
||||
const connected = await request('/api/channels/email/connect', {
|
||||
token: session.token,
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: '本地协议邮箱',
|
||||
displayName: 'Lingxi Receiver',
|
||||
account: 'receiver@local.test',
|
||||
password: 'pwd',
|
||||
imapHost: 'host.docker.internal',
|
||||
imapPort: 3143,
|
||||
imapSecure: false,
|
||||
imapFolder: 'INBOX',
|
||||
smtpHost: 'host.docker.internal',
|
||||
smtpPort: 3025,
|
||||
smtpSecure: false,
|
||||
syncDays: 7,
|
||||
},
|
||||
})
|
||||
assert.equal(connected.status, 'connected')
|
||||
assert.equal(connected.encryptedPassword, undefined)
|
||||
|
||||
const conversations = await request('/api/conversations?channel=email', { token: session.token })
|
||||
assert.ok(conversations.list.length >= 2)
|
||||
const newerConversation = conversations.list.find((item) => item.emailSubject === newerSubject)
|
||||
const olderConversation = conversations.list.find((item) => item.emailSubject === olderSubject)
|
||||
assert.ok(newerConversation)
|
||||
assert.ok(olderConversation)
|
||||
assert.ok(conversations.list.indexOf(newerConversation) < conversations.list.indexOf(olderConversation))
|
||||
|
||||
const olderMessages = await request(`/api/conversations/${olderConversation.id}/messages`, { token: session.token })
|
||||
await request(`/api/conversations/${olderConversation.id}/read`, {
|
||||
token: session.token,
|
||||
method: 'POST',
|
||||
body: { messageIds: olderMessages.map((message) => message.id) },
|
||||
})
|
||||
const afterRead = await request('/api/conversations?channel=email', { token: session.token })
|
||||
const relevantOrder = afterRead.list
|
||||
.filter((item) => [newerConversation.id, olderConversation.id].includes(item.id))
|
||||
.map((item) => item.id)
|
||||
assert.deepEqual(relevantOrder, [newerConversation.id, olderConversation.id])
|
||||
|
||||
let conversation
|
||||
let incoming
|
||||
for (const candidate of conversations.list.filter((item) => item.customer?.email === 'sender@local.test')) {
|
||||
const candidateMessages = await request(`/api/conversations/${candidate.id}/messages`, { token: session.token })
|
||||
const candidateIncoming = candidateMessages.find((message) => message.sender === 'customer' && message.emailAttachments?.length)
|
||||
if (candidateIncoming) {
|
||||
conversation = candidate
|
||||
incoming = candidateIncoming
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.ok(conversation)
|
||||
assert.ok(incoming)
|
||||
const attachment = await request(
|
||||
`/api/conversations/${conversation.id}/messages/${incoming.id}/attachment?attachmentId=${encodeURIComponent(incoming.emailAttachments[0].id)}`,
|
||||
{ token: session.token },
|
||||
)
|
||||
assert.equal(attachment.name, 'lingxi-proof.txt')
|
||||
assert.ok(attachment.dataUrl.startsWith('data:text/plain;base64,'))
|
||||
|
||||
const replyText = `Lingxi gateway API reply ${suffix}`
|
||||
const sent = await request(`/api/conversations/${conversation.id}/messages`, {
|
||||
token: session.token,
|
||||
method: 'POST',
|
||||
body: { text: replyText, autoTranslate: false },
|
||||
})
|
||||
assert.equal(sent.status, 'sent')
|
||||
assert.ok(sent.emailMessageId)
|
||||
|
||||
const receivedBySender = await waitForSenderMessage((message) => message.messageId === sent.emailMessageId)
|
||||
assert.equal(receivedBySender.text, replyText)
|
||||
assert.ok(receivedBySender.inReplyTo)
|
||||
|
||||
await request(`/api/conversations/${conversation.id}/read`, {
|
||||
token: session.token,
|
||||
method: 'POST',
|
||||
body: { messageIds: [incoming.id] },
|
||||
})
|
||||
const receiver = await fetchEmailMessages(localChannel('receiver@local.test'), { full: true })
|
||||
assert.equal(receiver.messages.find((message) => message.uid === incoming.emailUid)?.seen, true)
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
encryptEmailPassword,
|
||||
fetchEmailMessages,
|
||||
markEmailUidsSeen,
|
||||
sendEmailMessage,
|
||||
verifyEmailConnection,
|
||||
} from '../src/email.js'
|
||||
|
||||
const enabled = process.env.RUN_EMAIL_PROTOCOL_TEST === '1'
|
||||
|
||||
function channel(account) {
|
||||
return {
|
||||
id: `protocol-${account}`,
|
||||
type: 'email',
|
||||
account,
|
||||
displayName: account.startsWith('sender') ? 'Lingxi Sender' : 'Lingxi Receiver',
|
||||
encryptedPassword: encryptEmailPassword('pwd'),
|
||||
imapHost: process.env.EMAIL_TEST_HOST || '127.0.0.1',
|
||||
imapPort: Number(process.env.EMAIL_TEST_IMAP_PORT || 3143),
|
||||
imapSecure: false,
|
||||
imapFolder: 'INBOX',
|
||||
smtpHost: process.env.EMAIL_TEST_HOST || '127.0.0.1',
|
||||
smtpPort: Number(process.env.EMAIL_TEST_SMTP_PORT || 3025),
|
||||
smtpSecure: false,
|
||||
syncDays: 7,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMessage(target, predicate) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const result = await fetchEmailMessages(target, { full: true })
|
||||
const message = result.messages.find(predicate)
|
||||
if (message) return { message, result }
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
}
|
||||
throw new Error('等待邮件进入 IMAP INBOX 超时')
|
||||
}
|
||||
|
||||
test('真实 SMTP 发件、IMAP 收件、附件解析、已读与回复头', { skip: !enabled }, async () => {
|
||||
process.env.JWT_SECRET ||= 'lingxi-email-protocol-test-secret'
|
||||
const sender = channel('sender@local.test')
|
||||
const receiver = channel('receiver@local.test')
|
||||
const subject = `Lingxi protocol ${Date.now()}`
|
||||
|
||||
const senderCheck = await verifyEmailConnection(sender, 'pwd')
|
||||
const receiverCheck = await verifyEmailConnection(receiver, 'pwd')
|
||||
assert.ok(senderCheck.latencyMs >= 0)
|
||||
assert.ok(receiverCheck.latencyMs >= 0)
|
||||
assert.ok(!sender.encryptedPassword.includes('pwd'))
|
||||
|
||||
const sent = await sendEmailMessage(sender, {
|
||||
to: receiver.account,
|
||||
subject,
|
||||
text: 'Lingxi IMAP/SMTP real protocol message',
|
||||
attachments: [{
|
||||
filename: 'lingxi-proof.txt',
|
||||
content: Buffer.from('real email attachment'),
|
||||
contentType: 'text/plain',
|
||||
}],
|
||||
})
|
||||
assert.ok(sent.messageId)
|
||||
|
||||
const received = await waitForMessage(receiver, (message) => message.subject === subject)
|
||||
assert.equal(received.message.text, 'Lingxi IMAP/SMTP real protocol message')
|
||||
assert.equal(received.message.from.email, sender.account)
|
||||
assert.equal(received.message.attachments.length, 1)
|
||||
assert.equal(received.message.attachments[0].name, 'lingxi-proof.txt')
|
||||
assert.equal(Buffer.from(received.message.attachments[0].base64, 'base64').toString(), 'real email attachment')
|
||||
|
||||
await markEmailUidsSeen(receiver, [received.message.uid])
|
||||
const marked = await fetchEmailMessages(receiver, { full: true })
|
||||
assert.equal(marked.messages.find((message) => message.uid === received.message.uid)?.seen, true)
|
||||
|
||||
const reply = await sendEmailMessage(receiver, {
|
||||
to: sender.account,
|
||||
subject: `Re: ${subject}`,
|
||||
text: 'Reply received by Lingxi',
|
||||
inReplyTo: received.message.messageId,
|
||||
references: [received.message.messageId],
|
||||
})
|
||||
const replyReceived = await waitForMessage(sender, (message) => message.messageId === reply.messageId)
|
||||
assert.equal(replyReceived.message.inReplyTo, received.message.messageId)
|
||||
assert.ok(replyReceived.message.references.includes(received.message.messageId))
|
||||
})
|
||||
Reference in New Issue
Block a user