177 lines
6.4 KiB
JavaScript
177 lines
6.4 KiB
JavaScript
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)
|
|
}
|
|
}
|