Initial commit for mail-sr

This commit is contained in:
zeer
2026-04-25 20:51:08 +08:00
commit 37907dd2f5
14 changed files with 4628 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
npm-debug.log
.git
.gitignore
.DS_Store
mail.db
mail.db-shm
mail.db-wal
.env

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
PORT=3000
DB_PATH=./mail.db

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
.env
.DS_Store
mail.db
mail.db-shm
mail.db-wal
npm-debug.log*
yarn-debug.log*
yarn-error.log*

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
RUN mkdir -p /data
ENV NODE_ENV=production
ENV PORT=3000
ENV DB_PATH=/data/mail.db
EXPOSE 3000
CMD ["npm", "start"]

335
README.md Normal file
View File

@@ -0,0 +1,335 @@
# 多邮箱帐号收发系统
一个基于 Node.js + Express + SQLite 的多邮箱工作台,支持在网页中统一管理多个邮箱渠道与邮箱帐号,进行收信、查看邮件、发信,以及通过 API 调用邮件能力。
## 功能特性
- 支持配置多个邮箱渠道,如 QQ 邮箱、163 邮箱和自定义 IMAP/SMTP 渠道
- 支持导入邮箱帐号与授权码
- 三栏式 Web 界面:邮箱帐号、邮件列表、邮件详情
- 支持网页写邮件、发邮件、查看邮件详情
- 支持拉取多文件夹邮件,不限于 `INBOX`
- 支持按需补拉单封邮件正文,避免全量同步过慢
- 支持批量导入导出邮箱信息
- 提供 API 用于发邮件、获取邮件列表、获取最新一封邮件
## 技术栈
- Node.js
- Express
- SQLite `better-sqlite3`
- SMTP `nodemailer`
- IMAP `imapflow`
- 前端:原生 HTML / CSS / JavaScript
## 目录结构
```text
mail-sr/
├── public/
│ ├── index.html
│ ├── styles.css
│ └── app.js
├── db.js
├── mailService.js
├── server.js
├── Dockerfile
├── docker-compose.yml
└── README.md
```
## 本地启动
### 1. 安装依赖
```bash
npm install
```
### 2. 配置环境变量
```bash
cp .env.example .env
```
默认环境变量:
```env
PORT=3000
DB_PATH=./mail.db
```
### 3. 启动服务
```bash
npm start
```
访问地址:`http://localhost:3000`
## Docker 部署
项目已经提供 `Dockerfile``docker-compose.yml`,可以直接用 Docker Compose 部署。
### 1. 准备环境变量
```bash
cp .env.example .env
```
如果你不需要额外环境变量,默认配置即可。
### 2. 构建并启动
```bash
docker compose up -d --build
```
启动后访问:`http://localhost:3000`
### 3. 停止服务
```bash
docker compose down
```
### 4. 查看日志
```bash
docker compose logs -f
```
### 5. 数据持久化
Docker Compose 默认使用命名卷 `mail-sr-data` 持久化 SQLite 数据库,容器重建后数据不会丢失。
容器内数据库路径:
```text
/data/mail.db
```
## 内置邮箱渠道
系统默认会初始化以下渠道:
- QQ邮箱
- IMAP: `imap.qq.com:993`
- SMTP: `smtp.qq.com:465`
- 163邮箱
- IMAP: `imap.163.com:993`
- SMTP: `smtp.163.com:465`
说明:通常需要在邮箱后台开启 IMAP/SMTP并使用授权码而不是邮箱登录密码。
## Web 使用说明
### 1. 渠道管理
在设置弹窗中可以新增渠道,填写:
- 渠道名称
- 渠道标识 code
- IMAP 主机、端口、是否 SSL
- SMTP 主机、端口、是否 SSL
### 2. 单个邮箱帐号导入
在设置弹窗中填写:
- 渠道
- 邮箱帐号
- 授权码
- 发件人名称,可选
### 3. 批量导入导出
支持以下格式,每行一条:
```text
渠道名----帐号----授权码
```
例如:
```text
QQ邮箱----demo@qq.com----abcd1234
163邮箱----demo@163.com----efgh5678
```
说明:
- 导入时按“渠道名”匹配已有渠道
- 如果渠道不存在,会直接报错
- 如果同一渠道下邮箱已存在,则更新授权码
- 导出会导出全部帐号,格式同上
### 4. 收信
点击左侧邮箱帐号后会:
- 复制邮箱号到剪贴板
- 立即触发收信
- 拉取全部可读文件夹中的最近邮件
- 在中间栏展示邮件列表
- 在右侧显示邮件详情
为了避免 IMAP 全量拉取过慢,系统使用“两阶段策略”:
- 先同步邮件列表与头信息
- 打开某封邮件时再按需拉取正文详情
### 5. 发信
点击左上角“写邮件”按钮,选择当前邮箱帐号后即可发信。
## API 文档
### 健康检查
```bash
curl http://localhost:3000/api/health
```
### 查询渠道
```bash
curl http://localhost:3000/api/channels
```
### 新增渠道
```bash
curl -X POST http://localhost:3000/api/channels \
-H 'Content-Type: application/json' \
-d '{
"name": "企业邮箱",
"code": "corp",
"imap_host": "imap.example.com",
"imap_port": 993,
"imap_secure": true,
"smtp_host": "smtp.example.com",
"smtp_port": 465,
"smtp_secure": true
}'
```
### 查询帐号列表
```bash
curl http://localhost:3000/api/accounts
```
### 新增邮箱帐号
```bash
curl -X POST http://localhost:3000/api/accounts \
-H 'Content-Type: application/json' \
-d '{
"channel_id": 1,
"email": "your-account@qq.com",
"auth_code": "邮箱授权码",
"display_name": "业务邮箱"
}'
```
### 删除邮箱帐号
```bash
curl -X DELETE http://localhost:3000/api/accounts/1
```
### 批量导出帐号
```bash
curl http://localhost:3000/api/accounts/export
```
### 批量导入帐号
```bash
curl -X POST http://localhost:3000/api/accounts/import \
-H 'Content-Type: application/json' \
-d '{
"content": "QQ邮箱----demo@qq.com----abcd1234\n163邮箱----demo@163.com----efgh5678"
}'
```
### 发送邮件
```bash
curl -X POST http://localhost:3000/api/send \
-H 'Content-Type: application/json' \
-d '{
"accountId": 1,
"to": "target@example.com",
"subject": "测试邮件",
"text": "这是一封来自 API 的测试邮件"
}'
```
### 获取邮件列表
首次建议带 `refresh=true`,从远程 IMAP 同步最新邮件。
```bash
curl "http://localhost:3000/api/messages?accountId=1&limit=20&refresh=true"
```
### 获取最新一封邮件
```bash
curl "http://localhost:3000/api/messages/latest?accountId=1&refresh=true"
```
### 获取单封邮件详情
```bash
curl "http://localhost:3000/api/accounts/1/messages/INBOX%3A123?refresh=true"
```
说明:`uid` 需要使用 URL 编码。
## 数据说明
- `channels`:邮箱渠道配置
- `accounts`:邮箱帐号和授权码
- `mail_cache`:同步后的邮件缓存
默认本地数据库文件为:
```text
./mail.db
```
如果通过 Docker 运行,则默认为:
```text
/data/mail.db
```
## 部署建议
- 生产环境建议使用反向代理,如 Nginx 或 Traefik
- 建议通过 HTTPS 暴露服务
- 当前授权码存储在 SQLite 中,生产环境建议改为加密存储
- 建议增加访问控制与登录认证,避免未授权访问
## 当前实现边界
- 当前未实现附件上传与下载
- 当前未实现用户登录、权限和多租户隔离
- 当前邮件缓存为本地 SQLite不适合超大规模并发
- 当前 HTML 邮件正文做了基础清理,但不是完整沙箱渲染
## 后续建议
如果准备继续往生产方向演进,建议优先补以下能力:
1. 用户登录与权限管理
2. 授权码加密存储
3. 附件收发
4. 邮件标签、已读未读、分页
5. 定时自动拉取邮件
6. 发信模板和批量发信
7. 更完整的 HTML 邮件安全隔离策略

93
db.js Normal file
View File

@@ -0,0 +1,93 @@
const path = require('path');
const Database = require('better-sqlite3');
const dbPath = process.env.DB_PATH
? path.resolve(process.env.DB_PATH)
: path.join(__dirname, 'mail.db');
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
code TEXT NOT NULL UNIQUE,
imap_host TEXT NOT NULL,
imap_port INTEGER NOT NULL,
imap_secure INTEGER NOT NULL DEFAULT 1,
smtp_host TEXT NOT NULL,
smtp_port INTEGER NOT NULL,
smtp_secure INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL,
email TEXT NOT NULL UNIQUE,
auth_code TEXT NOT NULL,
display_name TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(channel_id) REFERENCES channels(id)
);
CREATE TABLE IF NOT EXISTS mail_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
uid TEXT NOT NULL,
folder TEXT NOT NULL DEFAULT 'INBOX',
subject TEXT,
from_name TEXT,
from_address TEXT,
to_addresses TEXT,
sent_at TEXT,
text_content TEXT,
html_content TEXT,
raw_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(account_id, uid),
FOREIGN KEY(account_id) REFERENCES accounts(id)
);
`);
const seedChannels = [
{
name: 'QQ邮箱',
code: 'qq',
imap_host: 'imap.qq.com',
imap_port: 993,
imap_secure: 1,
smtp_host: 'smtp.qq.com',
smtp_port: 465,
smtp_secure: 1,
},
{
name: '163邮箱',
code: '163',
imap_host: 'imap.163.com',
imap_port: 993,
imap_secure: 1,
smtp_host: 'smtp.163.com',
smtp_port: 465,
smtp_secure: 1,
},
];
const channelExistsStmt = db.prepare('SELECT id FROM channels WHERE code = ?');
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
)
`);
for (const channel of seedChannels) {
if (!channelExistsStmt.get(channel.code)) {
insertChannelStmt.run(channel);
}
}
module.exports = db;

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
version: '3.9'
services:
mail-sr:
build:
context: .
dockerfile: Dockerfile
container_name: mail-sr
restart: unless-stopped
ports:
- "3000:3000"
environment:
PORT: 3000
DB_PATH: /data/mail.db
env_file:
- .env
volumes:
- mail-sr-data:/data
volumes:
mail-sr-data:

227
mailService.js Normal file
View File

@@ -0,0 +1,227 @@
const nodemailer = require('nodemailer');
const { ImapFlow } = require('imapflow');
const { simpleParser } = require('mailparser');
function createSmtpTransport(channel, account) {
return nodemailer.createTransport({
host: channel.smtp_host,
port: channel.smtp_port,
secure: Boolean(channel.smtp_secure),
auth: {
user: account.email,
pass: account.auth_code,
},
});
}
function createImapClient(channel, account) {
return new ImapFlow({
host: channel.imap_host,
port: channel.imap_port,
secure: Boolean(channel.imap_secure),
auth: {
user: account.email,
pass: account.auth_code,
},
});
}
async function sendMail({ channel, account, payload }) {
const transporter = createSmtpTransport(channel, account);
const info = await transporter.sendMail({
from: payload.from || formatFrom(account),
to: payload.to,
cc: payload.cc,
bcc: payload.bcc,
subject: payload.subject,
text: payload.text,
html: payload.html,
});
return {
messageId: info.messageId,
accepted: info.accepted,
rejected: info.rejected,
response: info.response,
};
}
function formatFrom(account) {
if (account.display_name) {
return `${account.display_name} <${account.email}>`;
}
return account.email;
}
function normalizeAddressList(list = []) {
return list.map((item) => ({
name: item.name || '',
address: item.address || '',
}));
}
async function fetchLatestEmails({ channel, account, limit = 20, folder = 'INBOX', includeAllFolders = true }) {
const client = createImapClient(channel, account);
try {
await client.connect();
const folders = includeAllFolders ? await listReadableFolders(client) : [folder];
const candidates = [];
const perFolderLimit = Math.max(limit * 2, 20);
for (const folderName of folders) {
try {
const folderCandidates = await fetchFolderCandidates(client, folderName, perFolderLimit);
candidates.push(...folderCandidates);
} catch (_error) {
// Some providers expose special folders that are listed but not fetchable.
continue;
}
}
const targetMessages = candidates
.sort((a, b) => compareDatesDesc(a.date, b.date))
.slice(0, limit > 0 ? limit : candidates.length);
return hydrateMessages(client, targetMessages);
} finally {
await client.logout().catch(() => {});
}
}
async function listReadableFolders(client) {
const folders = [];
const listed = await client.list();
for (const mailbox of listed) {
if (mailbox.flags?.has('\\Noselect')) {
continue;
}
folders.push(mailbox.path);
}
if (!folders.length) {
return ['INBOX'];
}
return folders;
}
async function fetchFolderCandidates(client, folder, limit) {
const mailbox = await client.mailboxOpen(folder);
const total = mailbox.exists || 0;
if (!total) {
return [];
}
const messages = [];
const start = Math.max(total - limit + 1, 1);
for await (const message of client.fetch(`${start}:${total}`, {
uid: true,
envelope: true,
})) {
messages.push({
uid: `${folder}:${message.uid}`,
rawUid: String(message.uid),
folder,
subject: message.envelope?.subject || '',
from: normalizeAddressList(message.envelope?.from || []),
to: normalizeAddressList(message.envelope?.to || []),
date: message.envelope?.date?.toISOString?.() || null,
});
}
return messages;
}
async function hydrateMessages(client, messages) {
const result = [];
for (const message of messages) {
try {
await client.mailboxOpen(message.folder);
const downloaded = await client.download(message.rawUid, null, { uid: true });
const source = downloaded?.content ? await streamToString(downloaded.content) : '';
const simple = await simpleParser(source);
result.push({
...message,
subject: simple.subject || message.subject || '',
from: normalizeAddressList(simple.from?.value || message.from || []),
to: normalizeAddressList(simple.to?.value || message.to || []),
date: simple.date ? simple.date.toISOString() : message.date,
text: simple.text || '',
html: simple.html || '',
});
} catch (_error) {
result.push({
...message,
text: '',
html: '',
});
}
}
return result;
}
async function fetchMessageDetail({ channel, account, compositeUid }) {
const client = createImapClient(channel, account);
try {
await client.connect();
const [folder, rawUid] = splitCompositeUid(compositeUid);
await client.mailboxOpen(folder);
const envelope = await client.fetchOne(rawUid, { uid: true, envelope: true }, { uid: true });
const downloaded = await client.download(rawUid, null, { uid: true });
const source = downloaded?.content ? await streamToString(downloaded.content) : '';
const simple = await simpleParser(source);
return {
uid: compositeUid,
rawUid,
folder,
subject: simple.subject || envelope?.envelope?.subject || '',
from: normalizeAddressList(simple.from?.value || envelope?.envelope?.from || []),
to: normalizeAddressList(simple.to?.value || envelope?.envelope?.to || []),
date: simple.date ? simple.date.toISOString() : envelope?.envelope?.date?.toISOString?.() || null,
text: simple.text || '',
html: simple.html || '',
};
} finally {
await client.logout().catch(() => {});
}
}
function splitCompositeUid(value) {
const separatorIndex = String(value).indexOf(':');
if (separatorIndex === -1) {
return ['INBOX', String(value)];
}
return [
String(value).slice(0, separatorIndex),
String(value).slice(separatorIndex + 1),
];
}
function streamToString(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
}
function compareDatesDesc(a, b) {
const left = a ? new Date(a).getTime() : 0;
const right = b ? new Date(b).getTime() : 0;
return right - left;
}
module.exports = {
sendMail,
fetchLatestEmails,
fetchMessageDetail,
};

1816
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "mail-sr",
"version": "1.0.0",
"description": "Multi mailbox send and receive system",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js",
"test": "echo \"No automated tests yet\""
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"better-sqlite3": "^12.9.0",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"imapflow": "^1.3.2",
"mailparser": "^3.9.8",
"nodemailer": "^8.0.6"
}
}

481
public/app.js Normal file
View File

@@ -0,0 +1,481 @@
const state = {
channels: [],
accounts: [],
selectedAccountId: null,
messages: [],
selectedMessageUid: null,
};
const channelTree = document.getElementById('channel-tree');
const channelForm = document.getElementById('channel-form');
const accountForm = document.getElementById('account-form');
const accountSubmitBtn = document.getElementById('account-submit-btn');
const sendForm = document.getElementById('send-form');
const sendBtn = document.getElementById('send-btn');
const refreshMailsBtn = document.getElementById('refresh-mails-btn');
const loadMailsBtn = document.getElementById('load-mails-btn');
const selectedAccountTitle = document.getElementById('selected-account-title');
const selectedAccountMeta = document.getElementById('selected-account-meta');
const messageList = document.getElementById('message-list');
const messageDetail = document.getElementById('message-detail');
const toast = document.getElementById('toast');
const composeModal = document.getElementById('compose-modal');
const settingsModal = document.getElementById('settings-modal');
const composeBtn = document.getElementById('compose-btn');
const settingsBtn = document.getElementById('settings-btn');
const closeCompose = document.getElementById('close-compose');
const closeSettings = document.getElementById('close-settings');
const batchImportForm = document.getElementById('batch-import-form');
const batchImportContent = document.getElementById('batch-import-content');
const batchImportBtn = document.getElementById('batch-import-btn');
const exportAccountsBtn = document.getElementById('export-accounts-btn');
const copyExportBtn = document.getElementById('copy-export-btn');
boot();
async function boot() {
bindEvents();
await reloadBaseData();
}
function bindEvents() {
composeBtn.addEventListener('click', () => openModal(composeModal));
settingsBtn.addEventListener('click', () => openModal(settingsModal));
closeCompose.addEventListener('click', () => closeModal(composeModal));
closeSettings.addEventListener('click', () => closeModal(settingsModal));
composeModal.querySelector('.modal-backdrop').addEventListener('click', () => closeModal(composeModal));
settingsModal.querySelector('.modal-backdrop').addEventListener('click', () => closeModal(settingsModal));
channelForm.addEventListener('submit', onCreateChannel);
accountForm.addEventListener('submit', onCreateAccount);
batchImportForm.addEventListener('submit', onBatchImport);
sendForm.addEventListener('submit', onSendMail);
refreshMailsBtn.addEventListener('click', () => loadMessages(true));
loadMailsBtn.addEventListener('click', () => loadMessages(false));
exportAccountsBtn.addEventListener('click', onExportAccounts);
copyExportBtn.addEventListener('click', onCopyExportAccounts);
}
function openModal(modal) {
modal.classList.remove('hidden');
}
function closeModal(modal) {
modal.classList.add('hidden');
}
async function reloadBaseData() {
const [channels, accounts] = await Promise.all([
request('/api/channels'),
request('/api/accounts'),
]);
state.channels = channels;
state.accounts = accounts;
renderAccountOptions();
renderChannelTree();
}
async function onCreateChannel(event) {
event.preventDefault();
const formData = new FormData(channelForm);
const payload = Object.fromEntries(formData.entries());
payload.imap_secure = formData.get('imap_secure') === 'on';
payload.smtp_secure = formData.get('smtp_secure') === 'on';
await request('/api/channels', {
method: 'POST',
body: JSON.stringify(payload),
});
channelForm.reset();
channelForm.querySelector('[name="imap_secure"]').checked = true;
channelForm.querySelector('[name="smtp_secure"]').checked = true;
showToast('渠道已创建');
await reloadBaseData();
}
async function onCreateAccount(event) {
event.preventDefault();
const payload = Object.fromEntries(new FormData(accountForm).entries());
accountSubmitBtn.disabled = true;
accountSubmitBtn.textContent = '保存中...';
try {
await request('/api/accounts', {
method: 'POST',
body: JSON.stringify(payload),
});
accountForm.reset();
showToast('邮箱帐号已导入');
await reloadBaseData();
} finally {
accountSubmitBtn.disabled = false;
accountSubmitBtn.textContent = '导入帐号';
}
}
async function onSendMail(event) {
event.preventDefault();
if (!state.selectedAccountId) {
showToast('请先选择邮箱帐号', true);
return;
}
const payload = Object.fromEntries(new FormData(sendForm).entries());
sendBtn.disabled = true;
sendBtn.textContent = '发送中...';
try {
await request(`/api/accounts/${state.selectedAccountId}/messages/send`, {
method: 'POST',
body: JSON.stringify(payload),
});
sendForm.reset();
closeModal(composeModal);
showToast('邮件发送成功');
} finally {
sendBtn.disabled = false;
sendBtn.textContent = '发送';
}
}
async function onBatchImport(event) {
event.preventDefault();
const content = batchImportContent.value.trim();
if (!content) {
showToast('请先粘贴导入内容', true);
return;
}
batchImportBtn.disabled = true;
batchImportBtn.textContent = '导入中...';
try {
const result = await request('/api/accounts/import', {
method: 'POST',
body: JSON.stringify({ content }),
});
await reloadBaseData();
showToast(`导入完成,新增 ${result.created} 条,更新 ${result.updated}`);
} finally {
batchImportBtn.disabled = false;
batchImportBtn.textContent = '批量导入';
}
}
async function onExportAccounts() {
const result = await request('/api/accounts/export');
batchImportContent.value = result.content || '';
showToast('导出内容已填入文本框');
}
async function onCopyExportAccounts() {
const content = batchImportContent.value.trim();
if (!content) {
const result = await request('/api/accounts/export');
batchImportContent.value = result.content || '';
}
await copyText(batchImportContent.value);
showToast('导出内容已复制');
}
function renderAccountOptions() {
const select = accountForm.querySelector('select[name="channel_id"]');
select.innerHTML = '<option value="">选择邮箱渠道</option>' + state.channels
.map((channel) => `<option value="${channel.id}">${escapeHtml(channel.name)} (${escapeHtml(channel.code)})</option>`)
.join('');
}
function renderChannelTree() {
if (!state.channels.length) {
channelTree.innerHTML = '<div class="empty-hint">暂无渠道,点击底部设置新增</div>';
return;
}
channelTree.innerHTML = state.channels.map((channel) => {
const accounts = state.accounts.filter((account) => Number(account.channel_id) === Number(channel.id));
const items = accounts.length
? accounts.map((account) => `
<div class="account-item ${Number(state.selectedAccountId) === Number(account.id) ? 'active' : ''}">
<button
class="account-copy-btn"
data-account-id="${account.id}"
data-account-email="${escapeHtml(account.email)}"
title="点击复制邮箱号,并立即收信"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
${escapeHtml(account.email)}
</button>
<button
class="account-delete-btn"
data-delete-account-id="${account.id}"
data-delete-account-email="${escapeHtml(account.email)}"
title="删除该邮箱帐号"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
`).join('')
: '<div class="empty-hint" style="padding: 4px 8px 8px;">该渠道下暂无邮箱</div>';
return `
<div class="channel-group">
<div class="channel-header" data-channel-toggle="${channel.id}">
<span class="channel-name">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="chevron">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
${escapeHtml(channel.name)}
</span>
<span style="font-size:11px;color:var(--text-muted);font-weight:400">${accounts.length}</span>
</div>
<div class="channel-accounts" data-channel-accounts="${channel.id}">
${items}
</div>
</div>
`;
}).join('');
channelTree.querySelectorAll('[data-channel-toggle]').forEach((header) => {
header.addEventListener('click', () => {
header.classList.toggle('collapsed');
});
});
channelTree.querySelectorAll('[data-account-id]').forEach((button) => {
button.addEventListener('click', async () => {
const email = button.dataset.accountEmail || '';
let copied = false;
try {
await copyText(email);
copied = true;
} catch (_error) {
copied = false;
}
state.selectedAccountId = Number(button.dataset.accountId);
state.selectedMessageUid = null;
syncSelectionState();
renderChannelTree();
if (copied) {
showToast(`已复制: ${email}`);
}
await loadMessages(true, true);
});
});
channelTree.querySelectorAll('[data-delete-account-id]').forEach((button) => {
button.addEventListener('click', async (event) => {
event.stopPropagation();
const accountId = Number(button.dataset.deleteAccountId);
const email = button.dataset.deleteAccountEmail || '';
if (!window.confirm(`确认删除 ${email} 吗?`)) {
return;
}
await request(`/api/accounts/${accountId}`, { method: 'DELETE' });
if (Number(state.selectedAccountId) === accountId) {
state.selectedAccountId = null;
state.selectedMessageUid = null;
state.messages = [];
renderMessages();
syncSelectionState();
}
showToast(`已删除: ${email}`);
await reloadBaseData();
});
});
}
async function loadMessages(refresh, autoFocusLatest = false) {
if (!state.selectedAccountId) {
return;
}
const query = new URLSearchParams({
limit: '20',
refresh: refresh ? 'true' : 'false',
});
const messages = await request(`/api/accounts/${state.selectedAccountId}/messages?${query.toString()}`);
state.messages = messages;
if (autoFocusLatest || !state.selectedMessageUid) {
state.selectedMessageUid = messages[0]?.uid || null;
}
renderMessages();
syncSelectionState();
showToast(refresh ? '已收取最新邮件' : '已加载缓存邮件');
}
function syncSelectionState() {
const account = state.accounts.find((item) => Number(item.id) === Number(state.selectedAccountId));
const enabled = Boolean(account);
refreshMailsBtn.disabled = !enabled;
loadMailsBtn.disabled = !enabled;
sendBtn.disabled = !enabled;
if (!account) {
selectedAccountTitle.textContent = '选择邮箱帐号';
selectedAccountMeta.textContent = '左侧选择邮箱后开始操作';
return;
}
const channel = state.channels.find((item) => Number(item.id) === Number(account.channel_id));
selectedAccountTitle.textContent = account.email;
selectedAccountMeta.textContent = `${channel?.name || '未知渠道'} · ID: ${account.id}`;
}
function renderMessages() {
if (!state.messages.length) {
messageList.innerHTML = `
<div class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
<p>暂无邮件,点击刷新收取</p>
</div>`;
messageDetail.className = 'mail-detail empty';
messageDetail.innerHTML = `
<div class="empty-state">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
<p>选择一封邮件查看详情</p>
</div>`;
return;
}
messageList.innerHTML = state.messages.map((message) => `
<div class="message-card ${state.selectedMessageUid == message.uid ? 'active' : ''}" data-message-uid="${message.uid}">
<h3>${escapeHtml(message.subject || '(无主题)')}</h3>
<p>${escapeHtml(message.from?.name || message.from?.address || '')}</p>
<div class="message-meta">${escapeHtml(message.folder || 'INBOX')} · ${formatDate(message.date)}</div>
</div>
`).join('');
messageList.querySelectorAll('[data-message-uid]').forEach((node) => {
node.addEventListener('click', async () => {
state.selectedMessageUid = node.dataset.messageUid;
await loadMessageDetail(state.selectedMessageUid);
renderMessages();
});
});
const active = state.messages.find((message) => String(message.uid) === String(state.selectedMessageUid)) || state.messages[0];
if (!active) {
return;
}
messageDetail.className = 'mail-detail';
const bodyHtml = normalizeMessageBody(active);
messageDetail.innerHTML = `
<div class="reading-header">
<h3>${escapeHtml(active.subject || '(无主题)')}</h3>
</div>
<div class="reading-meta-grid">
<p><strong>发件人:</strong>${escapeHtml(active.from?.name || '')} ${escapeHtml(active.from?.address || '')}</p>
<p><strong>时间:</strong>${formatDate(active.date)}</p>
<p><strong>收件人:</strong>${escapeHtml((active.to || []).map((item) => item.address).join(', '))}</p>
<p><strong>文件夹:</strong>${escapeHtml(active.folder || 'INBOX')}</p>
</div>
<div class="reading-divider"></div>
<div class="message-body ${bodyHtml.isHtml ? 'html-body' : 'text-body'}">${bodyHtml.content}</div>
`;
}
function normalizeMessageBody(message) {
const html = String(message.html || '').trim();
const text = String(message.text || '').trim();
if (html) {
return { isHtml: true, content: sanitizeHtml(html) };
}
return { isHtml: false, content: escapeHtml(text || '该邮件暂无可展示正文') };
}
function sanitizeHtml(value) {
return String(value)
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/on\w+=/gi, 'data-blocked=');
}
async function loadMessageDetail(uid) {
if (!state.selectedAccountId || !uid) {
return;
}
try {
const detail = await request(`/api/accounts/${state.selectedAccountId}/messages/${encodeURIComponent(uid)}?refresh=true`);
state.messages = state.messages.map((message) => (String(message.uid) === String(uid) ? detail : message));
} catch (_error) {
}
}
async function copyText(value) {
if (!value) return;
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
return;
}
const input = document.createElement('textarea');
input.value = value;
input.setAttribute('readonly', 'readonly');
input.style.position = 'absolute';
input.style.left = '-9999px';
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
}
async function request(url, options = {}) {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
...options,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
showToast(data.error || '请求失败', true);
throw new Error(data.error || 'Request failed');
}
return data;
}
function showToast(message, error = false) {
toast.textContent = message;
toast.className = `toast ${error ? 'error' : ''}`;
setTimeout(() => {
toast.className = 'toast hidden';
}, 2500);
}
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function formatDate(value) {
if (!value) return '未知时间';
return new Date(value).toLocaleString('zh-CN');
}

219
public/index.html Normal file
View File

@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mail SR - 多邮箱工作台</title>
<link rel="stylesheet" href="/styles.css" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="app-container">
<!-- 左侧边栏 -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<rect x="2" y="4" width="20" height="16" rx="2" stroke="currentColor" stroke-width="2"/>
<path d="M2 8L12 14L22 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>Mail SR</span>
</div>
<button id="compose-btn" class="compose-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
写邮件
</button>
</div>
<div class="account-section">
<div class="section-header">
<span>我的邮箱</span>
</div>
<div id="channel-tree" class="account-list"></div>
</div>
<div class="sidebar-footer">
<button id="settings-btn" class="icon-btn" title="设置">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82.33l.06.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
</div>
</aside>
<!-- 邮件列表区 -->
<section class="mail-list-section">
<header class="mail-list-header">
<div class="header-left">
<h2 id="selected-account-title">选择邮箱帐号</h2>
<span id="selected-account-meta" class="account-info"></span>
</div>
<div class="header-actions">
<button id="refresh-mails-btn" class="action-btn" disabled title="刷新">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 20 10"></polyline>
<polyline points="1 20 1 14 4 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
</button>
<button id="load-mails-btn" class="action-btn" disabled title="加载缓存">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
</div>
</header>
<div class="mail-list">
<div id="message-list" class="message-items">
<div class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
<p>选择邮箱帐号查看邮件</p>
</div>
</div>
</div>
</section>
<!-- 邮件详情区 -->
<section class="mail-detail-section">
<article id="message-detail" class="mail-detail empty">
<div class="empty-state">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
<p>选择一封邮件查看详情</p>
</div>
</article>
</section>
</div>
<!-- 写邮件弹窗 -->
<div id="compose-modal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-content compose-modal-content">
<div class="modal-header">
<h3>写邮件</h3>
<button class="close-btn" id="close-compose">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form id="send-form" class="compose-form">
<div class="form-row">
<label>收件人</label>
<input type="text" name="to" placeholder="多个收件人用逗号分隔" required>
</div>
<div class="form-row">
<label>抄送</label>
<input type="text" name="cc" placeholder="可选">
</div>
<div class="form-row">
<label>主题</label>
<input type="text" name="subject" placeholder="邮件主题" required>
</div>
<div class="form-row body-row">
<textarea name="text" placeholder="撰写邮件内容..." required></textarea>
</div>
<div class="form-actions">
<button type="submit" class="send-btn" id="send-btn" disabled>发送</button>
</div>
</form>
</div>
</div>
<!-- 设置弹窗 -->
<div id="settings-modal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-content settings-modal-content">
<div class="modal-header">
<h3>设置</h3>
<button class="close-btn" id="close-settings">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="settings-content">
<div class="settings-section">
<h4>新增邮箱渠道</h4>
<form id="channel-form" class="settings-form">
<div class="form-row compact">
<input name="name" placeholder="渠道名称,如 QQ邮箱" required>
</div>
<div class="form-row compact">
<input name="code" placeholder="渠道标识,如 qq" required>
</div>
<div class="form-row compact">
<input name="imap_host" placeholder="IMAP Host" required>
</div>
<div class="form-row compact split">
<input name="imap_port" type="number" placeholder="IMAP Port" required>
<label class="checkbox"><input name="imap_secure" type="checkbox" checked> SSL</label>
</div>
<div class="form-row compact">
<input name="smtp_host" placeholder="SMTP Host" required>
</div>
<div class="form-row compact split">
<input name="smtp_port" type="number" placeholder="SMTP Port" required>
<label class="checkbox"><input name="smtp_secure" type="checkbox" checked> SSL</label>
</div>
<button type="submit" class="add-btn">新增渠道</button>
</form>
</div>
<div class="settings-section">
<h4>导入邮箱帐号</h4>
<form id="account-form" class="settings-form">
<div class="form-row compact">
<select name="channel_id" required>
<option value="">选择邮箱渠道</option>
</select>
</div>
<div class="form-row compact">
<input name="email" type="email" placeholder="邮箱帐号" required>
</div>
<div class="form-row compact">
<input name="auth_code" type="password" placeholder="授权码" required>
</div>
<div class="form-row compact">
<input name="display_name" placeholder="发件人名称(可选)">
</div>
<button type="submit" class="add-btn" id="account-submit-btn">导入帐号</button>
</form>
</div>
<div class="settings-section">
<h4>批量导入导出</h4>
<div class="batch-tip">每行一条,格式:渠道名----帐号----授权码</div>
<form id="batch-import-form" class="settings-form">
<div class="form-row compact">
<textarea id="batch-import-content" class="batch-textarea" placeholder="例如:&#10;QQ邮箱----demo@qq.com----xxxxxxxx&#10;163邮箱----demo@163.com----yyyyyyyy"></textarea>
</div>
<div class="inline-actions">
<button type="submit" class="add-btn" id="batch-import-btn">批量导入</button>
<button type="button" class="secondary-btn" id="export-accounts-btn">导出全部</button>
<button type="button" class="secondary-btn" id="copy-export-btn">复制导出内容</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script src="/app.js"></script>
</body>
</html>

780
public/styles.css Normal file
View File

@@ -0,0 +1,780 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
color-scheme: dark;
--bg-base: #0f172a;
--bg-sidebar: #0c1424;
--bg-card: #1e293b;
--bg-hover: #273548;
--bg-active: #1e3a5f;
--border: #334155;
--border-light: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent: #3b82f6;
--accent-light: #1e3a5f;
--accent-hover: #60a5fa;
--danger: #ef4444;
--danger-hover: #f87171;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 4px 12px rgba(0,0,0,0.35);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
body {
font-family: var(--font);
background: var(--bg-base);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
button,
input,
textarea,
select {
font: inherit;
}
button {
border: 0;
background: none;
cursor: pointer;
padding: 0;
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
input,
textarea,
select {
width: 100%;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-primary);
border-radius: var(--radius-sm);
padding: 9px 12px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
font-size: 14px;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.app-container {
display: grid;
grid-template-columns: 240px 320px 1fr;
height: 100vh;
overflow: hidden;
}
/* === Sidebar === */
.sidebar {
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 20px 16px 16px;
display: flex;
flex-direction: column;
gap: 14px;
border-bottom: 1px solid var(--border-light);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.logo svg {
color: var(--accent);
flex-shrink: 0;
}
.compose-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--accent);
color: #fff;
border-radius: var(--radius-md);
padding: 10px 14px;
font-size: 14px;
font-weight: 600;
transition: background 0.15s, transform 0.1s;
width: 100%;
}
.compose-btn:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
.compose-btn:active {
transform: translateY(0);
}
.account-section {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 12px 0;
}
.section-header {
padding: 4px 16px 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.account-list {
flex: 1;
overflow-y: auto;
padding: 0 8px;
}
.channel-group {
margin-bottom: 4px;
}
.channel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 8px 4px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
}
.channel-header:hover .chevron,
.channel-header:hover .channel-name {
color: var(--accent);
}
.channel-name {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
transition: color 0.15s;
}
.chevron {
transition: transform 0.2s, color 0.15s;
color: var(--text-muted);
}
.channel-header.collapsed .chevron {
transform: rotate(-90deg);
}
.channel-accounts {
overflow: hidden;
transition: max-height 0.2s ease;
}
.channel-header.collapsed + .channel-accounts {
display: none;
}
.account-item {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 0;
}
.account-copy-btn {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-primary);
transition: background 0.12s, color 0.12s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.account-copy-btn:hover {
background: var(--bg-hover);
color: var(--accent);
}
.account-item.active .account-copy-btn {
background: var(--bg-active);
color: var(--accent);
font-weight: 600;
}
.account-delete-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-muted);
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
flex-shrink: 0;
}
.account-item:hover .account-delete-btn {
opacity: 1;
}
.account-delete-btn:hover {
background: #fee2e2;
color: var(--danger);
}
.empty-hint {
padding: 16px;
font-size: 13px;
color: var(--text-muted);
text-align: center;
}
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--border-light);
display: flex;
justify-content: flex-end;
}
.icon-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-muted);
transition: background 0.15s, color 0.15s;
}
.icon-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* === Mail List Section === */
.mail-list-section {
border-right: 1px solid var(--border);
background: var(--bg-card);
display: flex;
flex-direction: column;
overflow: hidden;
}
.mail-list-header {
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border-light);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
.header-left {
min-width: 0;
}
#selected-account-title {
font-size: 15px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.account-info {
font-size: 12px;
color: var(--text-muted);
display: block;
margin-top: 2px;
}
.header-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.action-btn {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.action-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--accent);
}
.mail-list {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.message-items {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: var(--text-muted);
font-size: 14px;
}
.empty-state svg {
opacity: 0.3;
}
.message-card {
padding: 12px;
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.12s;
border: 1px solid transparent;
margin-bottom: 4px;
}
.message-card:hover {
background: var(--bg-hover);
}
.message-card.active {
background: var(--accent-light);
border-color: #bfdbfe;
}
.message-card h3 {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.message-card p {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.message-meta {
font-size: 11px;
color: var(--text-muted);
margin-top: 3px;
}
/* === Mail Detail Section === */
.mail-detail-section {
background: var(--bg-base);
overflow: hidden;
display: flex;
flex-direction: column;
}
.mail-detail {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
}
.mail-detail.empty {
display: flex;
align-items: center;
justify-content: center;
}
.mail-detail.empty .empty-state svg {
opacity: 0.2;
}
.reading-header h3 {
font-size: 18px;
font-weight: 700;
margin-bottom: 16px;
line-height: 1.4;
}
.reading-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 16px;
padding: 14px 16px;
background: var(--bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
margin-bottom: 20px;
}
.reading-meta-grid p {
font-size: 13px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reading-meta-grid strong {
color: var(--text-primary);
font-weight: 600;
}
.reading-divider {
height: 1px;
background: var(--border-light);
margin: 0 0 20px;
}
.message-body {
line-height: 1.8;
font-size: 14px;
color: var(--text-primary);
max-width: 720px;
}
.message-body.text-body {
white-space: pre-wrap;
background: var(--bg-card);
padding: 16px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.message-body.html-body {
background: var(--bg-card);
padding: 20px 24px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.message-body.html-body img {
max-width: 100%;
height: auto;
}
.message-body.html-body a {
color: var(--accent);
}
/* === Modal === */
.modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal.hidden {
display: none;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
}
.modal-content {
position: relative;
background: var(--bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md), 0 20px 40px rgba(0,0,0,0.15);
overflow: hidden;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.compose-modal-content {
width: 620px;
}
.settings-modal-content {
width: 520px;
}
.modal-header {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border-light);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.modal-header h3 {
font-size: 17px;
font-weight: 700;
}
.close-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-muted);
transition: background 0.15s, color 0.15s;
}
.close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.compose-form,
.settings-content {
padding: 20px 24px;
overflow-y: auto;
}
.form-row {
margin-bottom: 14px;
}
.form-row label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 6px;
}
.body-row {
flex: 1;
display: flex;
flex-direction: column;
}
.body-row textarea {
flex: 1;
min-height: 220px;
resize: vertical;
font-family: inherit;
line-height: 1.6;
}
.form-actions {
display: flex;
justify-content: flex-end;
padding-top: 8px;
}
.send-btn {
background: var(--accent);
color: #fff;
border-radius: var(--radius-md);
padding: 10px 24px;
font-size: 14px;
font-weight: 600;
transition: background 0.15s;
}
.send-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.settings-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.settings-section h4 {
font-size: 14px;
font-weight: 700;
margin-bottom: 12px;
color: var(--text-primary);
}
.settings-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.batch-tip {
margin-bottom: 10px;
font-size: 12px;
color: var(--text-muted);
}
.batch-textarea {
min-height: 150px;
resize: vertical;
line-height: 1.7;
}
.inline-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.form-row.compact {
margin-bottom: 0;
}
.form-row.split {
display: flex;
align-items: center;
gap: 10px;
}
.form-row.split input {
flex: 1;
}
.checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
flex-shrink: 0;
cursor: pointer;
}
.checkbox input {
width: auto;
}
.add-btn {
background: var(--accent);
color: #fff;
border-radius: var(--radius-md);
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
transition: background 0.15s;
margin-top: 4px;
}
.add-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.secondary-btn {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.secondary-btn:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent);
color: var(--text-primary);
}
/* === Toast === */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 20px;
border-radius: var(--radius-md);
background: #1a1d21;
color: #fff;
font-size: 14px;
font-weight: 500;
box-shadow: var(--shadow-md);
z-index: 2000;
max-width: 360px;
}
.toast.error {
background: var(--danger);
}
.toast.hidden {
display: none;
}
@media (max-width: 1100px) {
.app-container {
grid-template-columns: 220px 280px 1fr;
}
}
@media (max-width: 900px) {
.app-container {
grid-template-columns: 1fr;
}
.sidebar {
display: none;
}
.mail-list-section {
border-right: none;
}
}

595
server.js Normal file
View 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);
}),
]);
}