feat(history): complete messaging core before multi-account gateways
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import crypto from 'node:crypto'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { ImapFlow } from 'imapflow'
|
||||
import { simpleParser } from 'mailparser'
|
||||
|
||||
function encryptionKey() {
|
||||
const secret = process.env.EMAIL_CREDENTIAL_KEY || process.env.JWT_SECRET
|
||||
if (!secret) throw Object.assign(new Error('EMAIL_CREDENTIAL_KEY 或 JWT_SECRET 尚未配置'), { status: 503 })
|
||||
return crypto.createHash('sha256').update(secret).digest()
|
||||
}
|
||||
|
||||
export function encryptEmailPassword(password) {
|
||||
const iv = crypto.randomBytes(12)
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv)
|
||||
const encrypted = Buffer.concat([cipher.update(String(password), 'utf8'), cipher.final()])
|
||||
return `v1:${iv.toString('base64url')}:${cipher.getAuthTag().toString('base64url')}:${encrypted.toString('base64url')}`
|
||||
}
|
||||
|
||||
export function decryptEmailPassword(value) {
|
||||
const [version, ivValue, tagValue, encryptedValue] = String(value || '').split(':')
|
||||
if (version !== 'v1' || !ivValue || !tagValue || !encryptedValue) {
|
||||
throw Object.assign(new Error('Email 凭据无效,请重新连接邮箱'), { status: 409 })
|
||||
}
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivValue, 'base64url'))
|
||||
decipher.setAuthTag(Buffer.from(tagValue, 'base64url'))
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptedValue, 'base64url')),
|
||||
decipher.final(),
|
||||
]).toString('utf8')
|
||||
}
|
||||
|
||||
function imapClient(channel, password) {
|
||||
return new ImapFlow({
|
||||
host: channel.imapHost,
|
||||
port: Number(channel.imapPort || 993),
|
||||
secure: channel.imapSecure !== false,
|
||||
auth: { user: channel.account, pass: password },
|
||||
logger: false,
|
||||
tls: { rejectUnauthorized: channel.allowInvalidCertificate !== true },
|
||||
})
|
||||
}
|
||||
|
||||
function smtpTransport(channel, password) {
|
||||
return nodemailer.createTransport({
|
||||
host: channel.smtpHost,
|
||||
port: Number(channel.smtpPort || 465),
|
||||
secure: channel.smtpSecure !== false,
|
||||
auth: { user: channel.account, pass: password },
|
||||
tls: { rejectUnauthorized: channel.allowInvalidCertificate !== true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function verifyEmailConnection(channel, password) {
|
||||
const startedAt = Date.now()
|
||||
const imap = imapClient(channel, password)
|
||||
try {
|
||||
await imap.connect()
|
||||
await imap.mailboxOpen(channel.imapFolder || 'INBOX', { readOnly: true })
|
||||
} finally {
|
||||
if (imap.usable) await imap.logout().catch(() => undefined)
|
||||
}
|
||||
const smtp = smtpTransport(channel, password)
|
||||
await smtp.verify()
|
||||
smtp.close()
|
||||
return { latencyMs: Date.now() - startedAt }
|
||||
}
|
||||
|
||||
function address(value) {
|
||||
const item = value?.value?.[0]
|
||||
return {
|
||||
name: String(item?.name || item?.address || '').trim(),
|
||||
email: String(item?.address || '').trim().toLowerCase(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchEmailMessages(channel, { full = false } = {}) {
|
||||
const password = decryptEmailPassword(channel.encryptedPassword)
|
||||
const client = imapClient(channel, password)
|
||||
const messages = []
|
||||
try {
|
||||
await client.connect()
|
||||
await client.mailboxOpen(channel.imapFolder || 'INBOX')
|
||||
let uids
|
||||
if (!full && Number(channel.lastUid || 0) > 0) {
|
||||
uids = await client.search({ uid: `${Number(channel.lastUid) + 1}:*` }, { uid: true })
|
||||
} else {
|
||||
const days = Math.max(1, Math.min(365, Number(channel.syncDays || 30)))
|
||||
uids = await client.search({ since: new Date(Date.now() - days * 86400000) }, { uid: true })
|
||||
}
|
||||
uids = (uids || []).slice(-500)
|
||||
if (!uids.length) return { messages, lastUid: Number(channel.lastUid || 0) }
|
||||
|
||||
for await (const row of client.fetch(uids, {
|
||||
uid: true,
|
||||
envelope: true,
|
||||
flags: true,
|
||||
internalDate: true,
|
||||
source: true,
|
||||
}, { uid: true })) {
|
||||
const parsed = await simpleParser(row.source)
|
||||
const sender = address(parsed.from)
|
||||
const recipients = parsed.to?.value || []
|
||||
const attachments = (parsed.attachments || []).map((attachment, index) => ({
|
||||
id: `${row.uid}-${index + 1}`,
|
||||
name: attachment.filename || `attachment-${index + 1}`,
|
||||
mimeType: attachment.contentType || 'application/octet-stream',
|
||||
size: attachment.size || attachment.content?.length || 0,
|
||||
contentId: attachment.contentId,
|
||||
base64: attachment.content?.length <= 20 * 1024 * 1024
|
||||
? attachment.content.toString('base64')
|
||||
: undefined,
|
||||
}))
|
||||
messages.push({
|
||||
uid: Number(row.uid),
|
||||
messageId: String(parsed.messageId || `imap-${row.uid}`),
|
||||
inReplyTo: parsed.inReplyTo || undefined,
|
||||
references: Array.isArray(parsed.references) ? parsed.references : parsed.references ? [parsed.references] : [],
|
||||
subject: String(parsed.subject || '(无主题)').trim(),
|
||||
from: sender,
|
||||
to: recipients.map((item) => ({ name: item.name || item.address || '', email: String(item.address || '').toLowerCase() })),
|
||||
text: String(parsed.text || '').trim() || String(parsed.html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(),
|
||||
html: typeof parsed.html === 'string' ? parsed.html : undefined,
|
||||
date: (parsed.date || row.internalDate || new Date()).toISOString(),
|
||||
seen: row.flags?.has('\\Seen') || false,
|
||||
attachments,
|
||||
})
|
||||
}
|
||||
return {
|
||||
messages: messages.sort((a, b) => Date.parse(a.date) - Date.parse(b.date)),
|
||||
lastUid: Math.max(Number(channel.lastUid || 0), ...messages.map((message) => message.uid)),
|
||||
}
|
||||
} finally {
|
||||
if (client.usable) await client.logout().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmailMessage(channel, {
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
inReplyTo,
|
||||
references,
|
||||
attachments,
|
||||
}) {
|
||||
const password = decryptEmailPassword(channel.encryptedPassword)
|
||||
const transport = smtpTransport(channel, password)
|
||||
try {
|
||||
return await transport.sendMail({
|
||||
from: channel.displayName ? { name: channel.displayName, address: channel.account } : channel.account,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
inReplyTo,
|
||||
references,
|
||||
attachments,
|
||||
})
|
||||
} finally {
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function markEmailUidsSeen(channel, uids) {
|
||||
const values = [...new Set(uids.map(Number).filter(Number.isFinite))]
|
||||
if (!values.length) return
|
||||
const password = decryptEmailPassword(channel.encryptedPassword)
|
||||
const client = imapClient(channel, password)
|
||||
try {
|
||||
await client.connect()
|
||||
await client.mailboxOpen(channel.imapFolder || 'INBOX')
|
||||
await client.messageFlagsAdd(values, ['\\Seen'], { uid: true })
|
||||
} finally {
|
||||
if (client.usable) await client.logout().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user