Files
mail-rs/server.js
2026-04-25 20:57:24 +08:00

688 lines
19 KiB
JavaScript

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 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) });
});
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) {
return next();
}
if (req.path === '/auth/status' || req.path === '/auth/login' || req.path === '/health') {
return next();
}
if (!isAuthenticated(req)) {
return res.status(401).json({ error: '请先输入访问密码' });
}
return next();
});
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 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);
}),
]);
}