feat(history): complete messaging core before multi-account gateways

This commit is contained in:
wmq
2026-07-28 22:36:58 +08:00
parent cd06d049fe
commit 7f09132fad
54 changed files with 4781 additions and 601 deletions
+10
View File
@@ -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`))
})
+169
View File
@@ -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)
})
+86
View File
@@ -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))
})