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) })