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