Initial commit for mail-sr
This commit is contained in:
595
server.js
Normal file
595
server.js
Normal file
@@ -0,0 +1,595 @@
|
||||
require('dotenv').config();
|
||||
|
||||
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);
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
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 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);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user