170 lines
6.2 KiB
JavaScript
170 lines
6.2 KiB
JavaScript
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)
|
|
})
|