require('dotenv').config(); const crypto = require('crypto'); const path = require('path'); const express = require('express'); const cors = require('cors'); const db = require('./db'); const { sendMail, fetchLatestEmails, fetchMessageDetail } = require('./mailService'); const app = express(); const port = Number(process.env.PORT || 3000); const appPassword = String(process.env.APP_PASSWORD || '').trim(); const apiToken = String(process.env.API_TOKEN || '').trim(); const authCookieName = 'mail_sr_auth'; const authCookieValue = appPassword ? crypto.createHash('sha256').update(appPassword).digest('hex') : ''; app.use(cors()); app.use(express.json({ limit: '2mb' })); app.use(express.urlencoded({ extended: false })); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/auth/status', (req, res) => { res.json({ ok: true, authenticated: isAuthenticated(req), tokenEnabled: Boolean(apiToken) }); }); app.post('/api/auth/login', (req, res) => { if (!appPassword) { return res.json({ ok: true, authenticated: true }); } const password = String(req.body.password || ''); if (password !== appPassword) { return res.status(401).json({ error: '密码错误' }); } res.setHeader('Set-Cookie', serializeAuthCookie(authCookieValue)); res.json({ ok: true, authenticated: true }); }); app.post('/api/auth/logout', (_req, res) => { res.setHeader('Set-Cookie', serializeAuthCookie('', { maxAge: 0 })); res.json({ ok: true }); }); app.use('/api', (req, res, next) => { if (!appPassword && !apiToken) { return next(); } if (req.path === '/auth/status' || req.path === '/auth/login' || req.path === '/health') { return next(); } if (isApiTokenAuthenticated(req) || isAuthenticated(req)) { return next(); } return res.status(401).json({ error: '请先登录或提供有效的 API Token' }); }); const getChannelStmt = db.prepare('SELECT * FROM channels WHERE id = ?'); const getAccountStmt = db.prepare('SELECT * FROM accounts WHERE id = ?'); const listChannelsStmt = db.prepare(` SELECT c.*, COUNT(a.id) AS account_count FROM channels c LEFT JOIN accounts a ON a.channel_id = c.id GROUP BY c.id ORDER BY c.name ASC `); const listAccountsByChannelStmt = db.prepare(` SELECT a.*, c.name AS channel_name, c.code AS channel_code FROM accounts a JOIN channels c ON c.id = a.channel_id WHERE a.channel_id = ? ORDER BY a.email ASC `); const listAccountsStmt = db.prepare(` SELECT a.*, c.name AS channel_name, c.code AS channel_code FROM accounts a JOIN channels c ON c.id = a.channel_id ORDER BY c.name ASC, a.email ASC `); const listAccountsWithSecretsStmt = db.prepare(` SELECT a.email, a.auth_code, c.name AS channel_name FROM accounts a JOIN channels c ON c.id = a.channel_id ORDER BY c.name ASC, a.email ASC `); const getChannelByNameStmt = db.prepare('SELECT * FROM channels WHERE name = ?'); const getAccountByChannelEmailStmt = db.prepare('SELECT * FROM accounts WHERE channel_id = ? AND email = ?'); const insertChannelStmt = db.prepare(` INSERT INTO channels ( name, code, imap_host, imap_port, imap_secure, smtp_host, smtp_port, smtp_secure ) VALUES ( @name, @code, @imap_host, @imap_port, @imap_secure, @smtp_host, @smtp_port, @smtp_secure ) `); const insertAccountStmt = db.prepare(` INSERT INTO accounts (channel_id, email, auth_code, display_name) VALUES (@channel_id, @email, @auth_code, @display_name) `); const updateAccountAuthCodeStmt = db.prepare('UPDATE accounts SET auth_code = ? WHERE id = ?'); const deleteAccountStmt = db.prepare('DELETE FROM accounts WHERE id = ?'); const deleteMailCacheByAccountStmt = db.prepare('DELETE FROM mail_cache WHERE account_id = ?'); const deleteAccountByEmailStmt = db.prepare('DELETE FROM accounts WHERE email = ?'); const deleteMailCacheByAccountEmailStmt = db.prepare(` DELETE FROM mail_cache WHERE account_id IN (SELECT id FROM accounts WHERE email = ?) `); const listCacheByAccountStmt = db.prepare(` SELECT * FROM mail_cache WHERE account_id = ? ORDER BY datetime(COALESCE(sent_at, created_at)) DESC, id DESC LIMIT ? `); const latestCacheByAccountStmt = db.prepare(` SELECT * FROM mail_cache WHERE account_id = ? ORDER BY datetime(COALESCE(sent_at, created_at)) DESC, id DESC LIMIT 1 `); const getCacheByAccountUidStmt = db.prepare(` SELECT * FROM mail_cache WHERE account_id = ? AND uid = ? LIMIT 1 `); const upsertMailCacheStmt = db.prepare(` INSERT INTO mail_cache ( account_id, uid, folder, subject, from_name, from_address, to_addresses, sent_at, text_content, html_content, raw_json ) VALUES ( @account_id, @uid, @folder, @subject, @from_name, @from_address, @to_addresses, @sent_at, @text_content, @html_content, @raw_json ) ON CONFLICT(account_id, uid) DO UPDATE SET subject = excluded.subject, from_name = excluded.from_name, from_address = excluded.from_address, to_addresses = excluded.to_addresses, sent_at = excluded.sent_at, text_content = excluded.text_content, html_content = excluded.html_content, raw_json = excluded.raw_json `); app.get('/api/health', (_req, res) => { res.json({ ok: true }); }); app.get('/api/channels', (_req, res) => { const channels = listChannelsStmt.all().map((channel) => ({ ...channel, imap_secure: Boolean(channel.imap_secure), smtp_secure: Boolean(channel.smtp_secure), })); res.json(channels); }); app.post('/api/channels', (req, res) => { const payload = sanitizeChannelPayload(req.body); if (!payload.ok) { return res.status(400).json({ error: payload.error }); } try { const result = insertChannelStmt.run(payload.data); const channel = getChannelStmt.get(result.lastInsertRowid); res.status(201).json(channel); } catch (error) { res.status(400).json({ error: error.message }); } }); app.get('/api/accounts', (_req, res) => { res.json(listAccountsStmt.all().map(serializeAccount)); }); app.get('/api/channels/:id/accounts', (req, res) => { res.json(listAccountsByChannelStmt.all(req.params.id).map(serializeAccount)); }); app.post('/api/accounts', (req, res) => { const payload = sanitizeAccountPayload(req.body); if (!payload.ok) { return res.status(400).json({ error: payload.error }); } try { const result = insertAccountStmt.run(payload.data); const account = getAccountStmt.get(result.lastInsertRowid); res.status(201).json(serializeAccount(account)); } catch (error) { res.status(400).json({ error: error.message }); } }); app.get('/api/accounts/export', (_req, res) => { const content = listAccountsWithSecretsStmt .all() .map((account) => `${account.channel_name}----${account.email}----${account.auth_code}`) .join('\n'); res.json({ content }); }); app.post('/api/accounts/import', (req, res) => { try { const payload = sanitizeImportPayload(req.body); if (!payload.ok) { return res.status(400).json({ error: payload.error }); } const result = importAccounts(payload.lines); res.json(result); } catch (error) { res.status(resolveStatus(error)).json({ error: error.message }); } }); app.delete('/api/accounts/:id', (req, res) => { try { const account = ensureAccount(req.params.id); deleteAccountWithCache(account.id); if (Number(req.params.id) === 1 && account.email === 'demo@qq.com') { return res.json({ ok: true, removed: account.email }); } res.json({ ok: true, removed: account.email }); } catch (error) { res.status(resolveStatus(error)).json({ error: error.message }); } }); app.get('/api/accounts/:id/messages', async (req, res) => { try { const account = ensureAccount(req.params.id); const channel = ensureChannel(account.channel_id); const limit = clampLimit(req.query.limit); const refresh = String(req.query.refresh || '').toLowerCase() === 'true'; if (refresh) { const messages = await withTimeout( fetchLatestEmails({ channel, account, limit }), 45000, '收信超时,请稍后重试' ); persistMessages(account.id, messages); } const cached = listCacheByAccountStmt.all(account.id, limit).map(serializeCachedMail); res.json(cached); } catch (error) { res.status(resolveStatus(error)).json({ error: error.message }); } }); app.get('/api/accounts/:id/messages/latest', async (req, res) => { try { const account = ensureAccount(req.params.id); const channel = ensureChannel(account.channel_id); const refresh = String(req.query.refresh || 'true').toLowerCase() !== 'false'; if (refresh) { const messages = await withTimeout( fetchLatestEmails({ channel, account, limit: 1 }), 45000, '收信超时,请稍后重试' ); persistMessages(account.id, messages); } const latest = latestCacheByAccountStmt.get(account.id); if (!latest) { return res.status(404).json({ error: '该邮箱暂无邮件' }); } res.json(serializeCachedMail(latest)); } catch (error) { res.status(resolveStatus(error)).json({ error: error.message }); } }); app.get('/api/accounts/:id/messages/:uid', async (req, res) => { try { const account = ensureAccount(req.params.id); const channel = ensureChannel(account.channel_id); const refresh = String(req.query.refresh || 'true').toLowerCase() !== 'false'; const uid = decodeURIComponent(req.params.uid); if (refresh) { const message = await withTimeout( fetchMessageDetail({ channel, account, compositeUid: uid }), 45000, '加载邮件正文超时,请稍后重试' ); if (message) { persistMessages(account.id, [message]); } } const cached = getCacheByAccountUidStmt.get(account.id, uid); if (!cached) { return res.status(404).json({ error: '邮件不存在' }); } res.json(serializeCachedMail(cached)); } catch (error) { res.status(resolveStatus(error)).json({ error: error.message }); } }); app.post('/api/accounts/:id/messages/send', async (req, res) => { try { const result = await sendMailForAccount(req.params.id, req.body); res.json({ ok: true, ...result }); } catch (error) { res.status(resolveStatus(error)).json({ error: error.message }); } }); app.post('/api/send', async (req, res) => { const accountId = req.body.accountId; if (!accountId) { return res.status(400).json({ error: '缺少 accountId' }); } try { const result = await sendMailForAccount(accountId, req.body); res.json({ ok: true, ...result }); } catch (error) { res.status(resolveStatus(error)).json({ error: error.message }); } }); app.get('/api/messages/latest', async (req, res) => { const accountId = req.query.accountId; if (!accountId) { return res.status(400).json({ error: '缺少 accountId' }); } try { const account = ensureAccount(accountId); const channel = ensureChannel(account.channel_id); const refresh = String(req.query.refresh || 'true').toLowerCase() !== 'false'; if (refresh) { const messages = await withTimeout( fetchLatestEmails({ channel, account, limit: 1 }), 45000, '收信超时,请稍后重试' ); persistMessages(account.id, messages); } const latest = latestCacheByAccountStmt.get(account.id); if (!latest) { return res.status(404).json({ error: '该邮箱暂无邮件' }); } res.json(serializeCachedMail(latest)); } catch (error) { res.status(resolveStatus(error)).json({ error: error.message }); } }); app.get('/api/messages', async (req, res) => { const accountId = req.query.accountId; if (!accountId) { return res.status(400).json({ error: '缺少 accountId' }); } try { const account = ensureAccount(accountId); const channel = ensureChannel(account.channel_id); const limit = clampLimit(req.query.limit); const refresh = String(req.query.refresh || '').toLowerCase() === 'true'; if (refresh) { const messages = await withTimeout( fetchLatestEmails({ channel, account, limit }), 45000, '收信超时,请稍后重试' ); persistMessages(account.id, messages); } res.json(listCacheByAccountStmt.all(account.id, limit).map(serializeCachedMail)); } catch (error) { res.status(resolveStatus(error)).json({ error: error.message }); } }); app.get(/^(?!\/api).*/, (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.listen(port, () => { console.log(`mail-sr running at http://localhost:${port}`); }); function serializeAccount(account) { return { ...account, auth_code: undefined, }; } function isAuthenticated(req) { if (!appPassword) { return true; } const cookies = parseCookies(req.headers.cookie || ''); return cookies[authCookieName] === authCookieValue; } function isApiTokenAuthenticated(req) { if (!apiToken) { return false; } const bearerToken = getBearerToken(req.headers.authorization || ''); const headerToken = String(req.headers['x-api-token'] || '').trim(); return bearerToken === apiToken || headerToken === apiToken; } function getBearerToken(authorizationHeader) { const value = String(authorizationHeader || '').trim(); if (!value.toLowerCase().startsWith('bearer ')) { return ''; } return value.slice(7).trim(); } function parseCookies(cookieHeader) { return String(cookieHeader || '') .split(';') .map((item) => item.trim()) .filter(Boolean) .reduce((result, item) => { const separatorIndex = item.indexOf('='); if (separatorIndex === -1) { return result; } const key = item.slice(0, separatorIndex).trim(); const value = decodeURIComponent(item.slice(separatorIndex + 1).trim()); result[key] = value; return result; }, {}); } function serializeAuthCookie(value, options = {}) { const parts = [ `${authCookieName}=${encodeURIComponent(value)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', ]; if (process.env.NODE_ENV === 'production') { parts.push('Secure'); } if (typeof options.maxAge === 'number') { parts.push(`Max-Age=${options.maxAge}`); } return parts.join('; '); } function sanitizeChannelPayload(body) { const data = { name: String(body.name || '').trim(), code: String(body.code || '').trim().toLowerCase(), imap_host: String(body.imap_host || '').trim(), imap_port: Number(body.imap_port), imap_secure: body.imap_secure === false ? 0 : 1, smtp_host: String(body.smtp_host || '').trim(), smtp_port: Number(body.smtp_port), smtp_secure: body.smtp_secure === false ? 0 : 1, }; if (!data.name || !data.code || !data.imap_host || !data.smtp_host || !data.imap_port || !data.smtp_port) { return { ok: false, error: '渠道参数不完整' }; } return { ok: true, data }; } function sanitizeAccountPayload(body) { const data = { channel_id: Number(body.channel_id), email: String(body.email || '').trim(), auth_code: String(body.auth_code || '').trim(), display_name: String(body.display_name || '').trim() || null, }; if (!data.channel_id || !data.email || !data.auth_code) { return { ok: false, error: '帐号参数不完整' }; } return { ok: true, data }; } function sanitizeSendPayload(body) { const data = { from: body.from ? String(body.from).trim() : undefined, to: String(body.to || '').trim(), cc: body.cc ? String(body.cc).trim() : undefined, bcc: body.bcc ? String(body.bcc).trim() : undefined, subject: String(body.subject || '').trim(), text: body.text ? String(body.text) : undefined, html: body.html ? String(body.html) : undefined, }; if (!data.to || !data.subject || (!data.text && !data.html)) { return { ok: false, error: '发信参数不完整,需要 to、subject、text/html' }; } return { ok: true, data }; } function sanitizeImportPayload(body) { const content = String(body.content || '').replace(/\r\n/g, '\n').trim(); if (!content) { return { ok: false, error: '导入内容不能为空' }; } const lines = content .split('\n') .map((line) => line.trim()) .filter(Boolean); if (!lines.length) { return { ok: false, error: '导入内容不能为空' }; } return { ok: true, lines }; } function ensureAccount(id) { const account = getAccountStmt.get(id); if (!account) { const error = new Error('邮箱帐号不存在'); error.status = 404; throw error; } return account; } function ensureChannel(id) { const channel = getChannelStmt.get(id); if (!channel) { const error = new Error('邮箱渠道不存在'); error.status = 404; throw error; } return channel; } function importAccounts(lines) { const transaction = db.transaction((rawLines) => { let created = 0; let updated = 0; rawLines.forEach((line, index) => { const lineNumber = index + 1; const parts = line.split('----').map((item) => item.trim()); if (parts.length !== 3 || parts.some((item) => !item)) { const error = new Error(`第 ${lineNumber} 行格式错误,应为:渠道名----帐号----授权码`); error.status = 400; throw error; } const [channelName, email, authCode] = parts; const channel = getChannelByNameStmt.get(channelName); if (!channel) { const error = new Error(`第 ${lineNumber} 行渠道不存在:${channelName}`); error.status = 400; throw error; } const existing = getAccountByChannelEmailStmt.get(channel.id, email); if (existing) { updateAccountAuthCodeStmt.run(authCode, existing.id); updated += 1; return; } insertAccountStmt.run({ channel_id: channel.id, email, auth_code: authCode, display_name: null, }); created += 1; }); return { ok: true, created, updated, total: rawLines.length, }; }); return transaction(lines); } function persistMessages(accountId, messages) { const transaction = db.transaction((list) => { for (const message of list) { const from = message.from[0] || {}; upsertMailCacheStmt.run({ account_id: accountId, uid: message.uid, folder: message.folder || 'INBOX', subject: message.subject || '', from_name: from.name || '', from_address: from.address || '', to_addresses: JSON.stringify(message.to || []), sent_at: message.date || null, text_content: message.text || '', html_content: message.html || '', raw_json: JSON.stringify(message), }); } }); transaction(messages); } async function sendMailForAccount(accountId, body) { const account = ensureAccount(accountId); const channel = ensureChannel(account.channel_id); const payload = sanitizeSendPayload(body); if (!payload.ok) { const error = new Error(payload.error); error.status = 400; throw error; } return sendMail({ channel, account, payload: payload.data }); } function deleteAccountWithCache(accountId) { const transaction = db.transaction((id) => { deleteMailCacheByAccountStmt.run(id); deleteAccountStmt.run(id); }); transaction(accountId); } function serializeCachedMail(row) { return { id: row.id, uid: row.uid, folder: row.folder, subject: row.subject, from: { name: row.from_name, address: row.from_address, }, to: JSON.parse(row.to_addresses || '[]'), date: row.sent_at, text: row.text_content, html: row.html_content, raw: JSON.parse(row.raw_json), }; } function clampLimit(value) { const number = Number(value || 20); if (Number.isNaN(number)) { return 20; } return Math.min(Math.max(number, 1), 100); } function resolveStatus(error) { return error.status || 500; } function withTimeout(promise, timeoutMs, message) { return Promise.race([ promise, new Promise((_, reject) => { setTimeout(() => { const error = new Error(message); error.status = 504; reject(error); }, timeoutMs); }), ]); }