Compare commits

23 Commits

Author SHA1 Message Date
c109abc6f5 feat: sample.png 2025-08-02 11:28:01 +08:00
d57037781e feat: Display received time and fix timestamp timezone conversion. 2025-08-02 11:26:41 +08:00
e936fbc140 feat(docker): 安装 tzdata 并设置 TZ=Asia/Shanghai 以让 Node.js 使用上海时区 2025-08-02 10:42:43 +08:00
a96aa6e073 feat(docker): 安装 tzdata 并设置 TZ=Asia/Shanghai 以让 Node.js 使用上海时区 2025-08-02 10:00:47 +08:00
893fac1ff2 feat(docker): 安装 tzdata 并设置 TZ=Asia/Shanghai 以让 Node.js 使用上海时区 2025-08-02 09:56:38 +08:00
be59fa85de feat(backend): 实施连接与数据层速率限制
为了增强服务的稳定性和安全性,防止滥用行为,本次提交引入了两个核心的速率限制和验证机制:

1. **连接层验证 (`onConnect`)**
   - **IP 速率限制**:在 `connectionValidator.js` 中实现。限制单个 IP 地址每分钟最多 20 次连接,超过限制将临时封禁 5 分钟,以防御暴力连接攻击。
   - **反向 DNS (PTR) 检查**:要求所有公共网络连接必须具有有效的 PTR 记录,用于区分合法邮件服务器和僵尸网络。

2. **数据层验证 (`onData`)**
   - **发件人域速率限制**:在 `rateLimiter.js` 中实现。在邮件数据传输阶段,限制单个发件人域名每分钟最多发送 10 封邮件,超过限制的域名将被临时封禁 5 分钟,以防止单一来源的邮件泛滥。

**主要变更:**
- 新增 `backend/connectionValidator.js`:处理连接时的 IP 速率限制和 PTR 验证。
- 新增 `backend/rateLimiter.js`:在 `onData` 阶段对发件人域名进行速率限制。
- 在 `backend/app.js` 的 `SMTPServer` 配置中集成了 `onConnect` 验证钩子。
- 在 `backend/saveEmail.js` 中调用速率限制器,并在超限时拒绝邮件。
- 更新日志模块 (`logger.js`, `db.js`),输出更清晰,并记录安全相关事件。
- 新增 `backend/SECURITY_POLICIES.md` 文档,详细说明所有安全策略和可配置参数。
2025-08-01 22:59:40 +08:00
3ad3849fde feat:fix README.md 2025-07-31 11:48:45 +08:00
99504e53ea feat:fix README.md 2025-07-31 11:46:43 +08:00
4787c9e2e0 feat:fix README.md 2025-07-31 11:45:40 +08:00
f8a4fb3561 feat:fix README.md 2025-07-31 11:44:51 +08:00
eecac11240 feat:fix docker-compose 2025-07-31 11:28:20 +08:00
9ceb96d9c7 feat:fix docker-compose 2025-07-31 11:00:22 +08:00
5b7cc39496 feat:fix docker-compose.full.yml 2025-07-31 09:05:09 +08:00
a7d8774157 feat:fix docker-compose.full.yml 2025-07-31 08:44:10 +08:00
91b3bc9640 feat:add certs dir 2025-07-31 08:13:51 +08:00
2b84fffacb feat:fix docker-compose-full.yml 2025-07-31 07:39:19 +08:00
d9a744dda4 nothing 2025-07-30 18:13:03 +08:00
f39b1f0246 feat:fix frontend .env 2025-07-30 18:06:20 +08:00
90ec3e5fc5 nothing 2025-07-30 16:32:58 +08:00
1f01ff97ad feat: fix WebSocket connection timeout 600s,overwrite 60s timeout 2025-07-30 16:23:10 +08:00
5e89bff405 feat: fix compose.env and compsoe.yml 2025-07-30 14:15:15 +08:00
a681494b82 feat: fix compose.env 2025-07-30 14:11:44 +08:00
2994a48e19 feat: fix compose.yml ;add frontend .env properties 2025-07-30 13:53:21 +08:00
26 changed files with 571 additions and 165 deletions

3
.gitignore vendored
View File

@@ -5,4 +5,5 @@
/frontend/dist /frontend/dist
plan.md plan.md
info.md info.md
docker-compose.self.yml # docker-compose.self.yml
compose.self.env

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 shenjianZ
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

150
README.md
View File

@@ -1,123 +1,95 @@
# 轻量级临时邮件项目 (Email Unlimit) # Email Unlimit - 轻量级、可自托管的临时邮件解决方案
本项目是一个轻量级的、可自托管的临时邮件解决方案。它允许您使用自己的域名接收邮件,并通过一个简洁的网页界面来查看这些邮件。 本项目是一个轻量级的、可自托管的临时邮件解决方案。它允许您使用自己的域名接收邮件,并通过一个简洁的网页界面来查看这些邮件。
`Mailu` 等复杂的邮件套件不同,本项目采用了一个极简的 Node.js 服务来直接接收和处理邮件,部署和维护都非常简单。 `Mailu` 等复杂的邮件套件不同,本项目采用了一个极简的 Node.js 服务来直接接收和处理邮件,部署和维护都非常简单。
**在线体验:** <a href="https://mail.shenjianl.cn" target="_blank">https://mail.shenjianl.cn</a>
**网站截图**
![sample.png](./sample.png)
## 技术架构 ## 技术架构
* **前端 (Frontend)**: 使用 Vue.js 构建的单页面应用,负责展示收到的邮件列表。 * **前端 (Frontend)**: 使用 Vue.js 构建的单页面应用,负责展示收到的邮件列表。
* **后端 (Backend)**: * **后端 (Backend)**:
* 使用 Node.js 和 Express 搭建的 API 服务器。 * 使用 Node.js 和 Express 搭建的 API 服务器。
* 内置一个轻量级的 SMTP 服务器 (`smtp-server`),用于直接接收邮件,无需外部邮件服务。 * 内置一个轻量级的 SMTP 服务器 (`smtp-server`),用于直接接收邮件,无需外部邮件服务。
* 负责将收到的邮件解析并存入数据库 * 通过 WebSocket 实现新邮件的实时推送
* **数据库 (Database)**: 需要一个外部的 MySQL 数据库来存储邮件信息。 * **数据库 (Database)**: 使用 MySQL 存储邮件信息,确保数据的安全性和持久性
* **部署 (Deployment)**: 后端服务通过 Docker Compose 进行容器化部署,前端静态文件由宿主机的 Nginx 提供服务 * **部署 (Deployment)**: 使用 Docker Compose 统一管理后端服务、数据库和 Nginx实现一键部署
## 部署要求 ## 前置条件
在开始之前,<EFBFBD><EFBFBD>确保您已准备好以下环境 在开始之前,确保您已满足以下条件
1. 一台拥有公网 IP 的 Linux 服务器。 ### 1. 硬件与域名
2. 一个您自己的域名 - 一台拥有公网 IP 的云服务器
3. 服务器上已安装 `Docker``Docker Compose` - 一个您自己的域名(建议已完成备案)
4. 服务器上已安装 `Nginx`
5. 一个可用的外部 MySQL 数据库,并已创建好数据库。
6. 本地开发环境已安装 `Node.js``npm` (用于构建前端)。
## 部署步骤 ### 2. 软件环境
- 服务器上已安装 [Docker 和 Docker Compose](https://docs.docker.com/get-docker/)。
- 本地开发环境已安装 [Node.js](https://nodejs.org/) (仅用于构建前端)。
### 步骤 1: 配置域名 DNS ### 3. 域名 DNS 配置
要让邮件能正确发送到您的服务器,您必须配置域名的 `A` 记录和 `MX` 记录。假设您的域名是 `example.com`,您希望用于收信的子域名是 `mail.example.com`
要让邮件能正确发送到您的服务器,您必须配置域名的 `MX` 记录。 | 类型 | 主机记录/名称 | 记录值/指向 | 备注 |
| :--- | :--- | :--- | :--- |
1. 登录您的域名注册商(如 GoDaddy, Cloudflare 等)。 | A | mail | `您的服务器公网 IP` | 将 `mail` 子域名指向您的服务器 |
2. 找到 DNS 解析设置。 | MX | @ | `mail.example.com` | 优先级设置为 10 |
3. 添加一条 `MX` 记录:
* **类型 (Type)**: `MX`
* **名称 (Name/Host)**: `@` (代表您的根域名)
* **值 (Value/Points to)**: `您的服务器公网 IP 地址`
* **优先级 (Priority)**: `10`
> **注意**: DNS 记录生效可能需要几分钟到几小时。 > **注意**: DNS 记录生效可能需要几分钟到几小时。
### 步骤 2: 部署后端服务 ### 4. 申请 SSL/TLS 证书
为了启用 HTTPS您需要为您的域名 `mail.example.com` 申请 SSL 证书。您可以从 Let's Encrypt、阿里云、腾讯云等证书颁发机构免费获取。
1. 将本项目克隆或上传到您的服务器。 ## 部署步骤
2. 进入项目根目录,编辑 `docker-compose.yml` 文件。
3. **填写您的外部数据库连接信息** 本部署方案使用 Docker Compose 统一管理所有服务Nginx, Backend, MySQL实现一键启动无需在宿主机上安装 Nginx 或其他依赖。
```yaml
services: ### 步骤 1: 获取并准备项目
backend: 将项目代码下载并解压到您的服务器,例如 `/data/email-unlimit` 目录。
# ...
environment: ### 步骤 2: 上传 SSL 证书
- DB_HOST=your_external_db_host # 替换为您的外部数据库主机名或IP 将您申请到的 SSL 证书文件(通常是 `.pem`/.`crt``.key` 文件)上传到项目的 `certs` 目录下 (`/data/email-unlimit/certs`)。
- DB_USER=your_external_db_user # 替换为您的数据库用户名
- DB_PASSWORD=your_external_db_password # 替换为您的数据库密码 ### 步骤 3: 构建前端静态文件 (本地环境)
- DB_NAME=your_external_db_name # 替换为您的数据库名称 1. 在您的**本地开发机**上,进入 `frontend` 目录,修改 `.env` 环境变量文件:
```env
VITE_APP_DOMAIN=example.com # 改为你的主域名
``` ```
4. 在 `backend` 目录下有一个 `init.sql` 文件,请手动将其中的 SQL 命令在您的外部数据库中执行,以创建所需的表。 2. 安装依赖并构建:
5. 在项目根目录,使用 Docker Compose 启动后端服务:
```bash
docker-compose up -d --build
```
此命令会构建并以后台模式启动后端容器。服务将监听服务器的 `5182` (API) 和 `25` (SMTP) 端口。
### 步骤 3: 构建和部署前端
1. **在您的本地开发机上**,进入 `frontend` 目录。
2. 安装依赖并构建静态文件:
```bash ```bash
cd frontend
npm install npm install
npm run build npm run build
``` ```
这将在 `frontend/dist` 目录下生成所有用于部署的静态文件 3. 将构建生成的 `dist` 目录(位于 `frontend/dist`**完整上传**到服务器的 `/data/email-unlimit/frontend` 目录下
3. 将 `frontend/dist` 目录下的 **所有文件** 上传到您服务器的指定位置,例如 `/var/www/email-unlimit`。
### 步骤 4: 配置宿主机 Nginx
1. 在服务器上,为您的应用创建一个 Nginx 配置文件,例如 `/etc/nginx/sites-available/email.conf`。
2. 将以下配置写入该文件。请务必将 `your_domain.com` 和 `root` 路径修改为您自己的配置。
### 步骤 4: 修改 Nginx 配置 (服务器)
编辑项目根目录下的 `nginx.full.conf` 文件,仅需修改以下三项:
```nginx ```nginx
server { server_name mail.example.com; # 改为您自己的域名
listen 443 ssl; ssl_certificate /etc/nginx/certs/your_certificate.pem; # 改为您自己的证书文件名
server_name mail.shenjianl.cn; # 替换为您的域 ssl_certificate_key /etc/nginx/certs/your_certificate.key; # 改为您自己的密钥文件
ssl_certificate /usr/local/nginx/conf/ssl_certificate/mail/mail.shenjianl.cn_bundle.pem;
ssl_certificate_key /usr/local/nginx/conf/ssl_certificate/mail/mail.shenjianl.cn.key;
# 前端静态文件路径
root /data/email-unlimit/frontend/dist;
index index.html;
# 处理 Vue Router 的 history 模式
location / {
try_files $uri $uri/ /index.html;
}
# 将 /api 请求反向代理到后端 Docker 容器
location /api {
proxy_pass http://localhost:5182;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
``` ```
3. 启用该配置并重启 Nginx > **注意**: 配置文件中的证书路径 `/etc/nginx/certs/` 是容器内的路径,它会映射到宿主机的 `/data/email-unlimit/certs` 目录,请确保文件名正确。
### 步骤 5: 启动服务 (服务器)
在服务器的项目根目录 (`/data/email-unlimit`) 下执行以下命令:
```bash ```bash
# 创建软链接 # -f 指定使用 docker-compose.full.yml 配置文件,-d 表示后台运行
sudo ln -s /etc/nginx/sites-available/email.conf /etc/nginx/sites-enabled/ docker compose -f docker-compose.full.yml up -d
# 测试配置语法
sudo nginx -t
# 重启 Nginx
sudo systemctl restart nginx
``` ```
服务启动后,您可以通过 `docker compose -f docker-compose.full.yml logs -f` 查看实时日志。
## 如何使用 ## 如何使用
1. **访问您的网站**: 在浏览器中打开 `http://your_domain.com` 1. **访问您的网站**: 在浏览器中打开 `https://mail.example.com` (替换为您的域名)
2. **发送测试邮件**: 使用任何邮箱客户端,向 `anything@your_domain.com` (例如 `test@your_domain.com`) 发送一封邮件。 2. **发送测试邮件**: 使用任何邮箱客户端,向 `anything@example.com` (例如 `test@example.com`) 发送一封邮件。
3. **查看邮件**: 在网站输入您刚刚使用的收件人地址 (`anything@your_domain.com`),点击查询,即可看到收到的邮件。 3. **查看邮件**: 在网站首页的输入框中输入您刚刚使用的收件人地址 (`test@example.com`),点击查询,即可看到收到的邮件。
## 许可证
本项目采用 MIT 许可证。详情请参阅 [LICENSE](LICENSE) 文件。

View File

@@ -1,5 +1,10 @@
FROM node:20-alpine FROM node:20-alpine
# Set timezone to Asia/Shanghai FIRST
# This ensures that any native modules compiled during npm install are linked correctly.
RUN apk add --no-cache tzdata
ENV TZ=Asia/Shanghai
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY package*.json ./ COPY package*.json ./

View File

@@ -0,0 +1,76 @@
# 邮件接收服务安全策略文档
本文档详细说明了为保护邮件接收服务而实施的各项安全与反滥用策略。这些策略旨在构建一个多层次的纵深防御体系,有效过滤垃圾邮件和恶意连接,同时保障服务的稳定性和安全性。
## 1. 连接层防御策略 (Connection-Level Policies)
这些策略在SMTP连接建立的最初阶段 (`onConnect`) 生效,以便在消耗最少资源的情况下,快速拒绝掉可疑的连接。
### 1.1 IP 地址频率限制 (IP-based Rate Limiting)
- **作用**: 防止单个IP地址在短时间内发起大量连接有效遏制暴力攻击和自动化脚本滥用。
- **策略**:
- **限制**: 每个IP地址每分钟最多允许 **20** 次连接。
- **惩罚**: 超过限制的IP地址将被封禁 **5** 分钟。
- **日志**:
- 当IP被封禁时会记录一条警告日志。
- 当连接因为IP被封禁或超速而被拒绝时会记录一条警告日志。
- **SMTP响应码**: `421` (服务不可用,请稍后重试)
- **配置文件**: `backend/connectionValidator.js`
### 1.2 反向DNS检查 (PTR Record Check)
- **作用**: 验证连接来源IP是否拥有一个有效的反向DNSPTR记录。这是区分正规邮件服务器和僵尸网络/垃圾邮件程序的有效手段。
- **策略**:
- 所有连接到本服务的公网IP地址都必须拥有一个可解析的PTR记录。
- 对于没有PTR记录的连接将直接拒绝。
- 本地和私有网络地址 (`127.0.0.1`, `10.x.x.x`, `192.168.x.x`, `::1`) 会被自动豁免。
- **日志**:
- 成功或失败的PTR查询都会被记录。
- **SMTP响应码**: `550` (请求的操作未执行,连接源不可信)
- **配置文件**: `backend/connectionValidator.js`
---
## 2. 数据层防御策略 (Data-Level Policies)
这些策略在SMTP连接建立后客户端开始发送邮件数据时 (`onData`) 生效,提供更精细的控制。
### 2.1 发件人域名频率限制 (Sender Domain Rate Limiting)
- **作用**: 在IP层验证通过后进一步限制来自**同一个发件人域名**的邮件接收频率。这可以防止某个合法来源(例如一个大型邮件服务提供商)下的单一账户被滥用。
- **策略**:
- **限制**: 每个发件人域名每分钟最多允许接收 **10** 封邮件。
- **惩罚**: 超过限制的域名将被封禁 **5** 分钟。
- **日志**:
- 当域名被封禁时,会记录一条警告日志。
- 当邮件因为域名被封禁或超速而被拒绝时,会记录一条警告日志。
- **SMTP响应码**: `421` (服务不可用,因策略限制请稍后重试)
- **配置文件**: `backend/rateLimiter.js`
---
## 3. 传输层安全 (Transport-Layer Security)
### 3.1 启用 STARTTLS
- **作用**: 允许客户端将一个普通的SMTP连接升级为安全的TLS加密连接。这可以保护邮件内容在传输过程中不被窃听或篡改。
- **策略**:
- 服务器在25端口上宣告支持 `STARTTLS`
- 优先使用加密连接。
- **配置文件**: `backend/app.js` (在 `SMTPServer` 的配置中)
---
## 4. 可配置参数详解
下表详细解释了各项策略中的可配置参数,您可以根据实际需求在对应的文件中进行调整。
| 参数 (Parameter) | 所在文件 (File Location) | 作用描述 | 默认值 | 单位 (Unit) |
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- | ------------------ | -------------------- |
| `IP_RATE_LIMIT` | `backend/connectionValidator.js` | 在指定时间窗口内单个IP允许的最大**连接次数**。 | `20` | 次 (Connections) |
| `IP_TIME_WINDOW` | `backend/connectionValidator.js` | IP频率限制的时间窗口。 | `60 * 1000` | 毫秒 (1 分钟) |
| `IP_BAN_DURATION` | `backend/connectionValidator.js` | IP因超速被封禁的持续时间。 | `5 * 60 * 1000` | 毫秒 (5 分钟) |
| `RATE_LIMIT` | `backend/rateLimiter.js` | 在指定时间窗口内,单个**发件人域名**允许的最大**邮件数量**。 | `10` | 封 (Emails) |
| `TIME_WINDOW` | `backend/rateLimiter.js` | 域名频率限制的时间窗口。 | `60 * 1000` | 毫秒 (1 分钟) |
| `BAN_DURATION` | `backend/rateLimiter.js` | 域名因超速被封禁的持续时间。 | `5 * 60 * 1000` | 毫秒 (5 分钟) |

View File

@@ -8,6 +8,7 @@ const { saveEmail } = require('./saveEmail');
const emitter = require('./eventEmitter'); const emitter = require('./eventEmitter');
const logger = require('./logger'); const logger = require('./logger');
const morgan = require('morgan'); const morgan = require('morgan');
const { validateConnection } = require('./connectionValidator');
const app = express(); const app = express();
const apiPort = 5182; const apiPort = 5182;
@@ -29,7 +30,7 @@ app.get('/api/messages', async (req, res) => {
try { try {
const [rows] = await db.execute( const [rows] = await db.execute(
'SELECT id, sender, recipient, subject, body, received_at FROM emails WHERE recipient LIKE ? ORDER BY received_at DESC', 'SELECT id, sender, recipient, subject, body, CAST(received_at AS CHAR) as received_at FROM emails WHERE recipient LIKE ? ORDER BY received_at DESC',
[`%${recipient}%`] [`%${recipient}%`]
); );
res.json(rows); res.json(rows);
@@ -162,8 +163,13 @@ server.listen(apiPort, () => {
// Configure and start SMTP server // Configure and start SMTP server
const smtpServer = new SMTPServer({ const smtpServer = new SMTPServer({
secure: false, // Enable STARTTLS
authOptional: true, authOptional: true,
disabledCommands: ['AUTH'], disabledCommands: ['AUTH'],
onConnect(session, callback) {
logger.info('Connection received from', { remoteAddress: session.remoteAddress });
return validateConnection(session, callback);
},
onData(stream, session, callback) { onData(stream, session, callback) {
logger.info('Receiving email...', { session }); logger.info('Receiving email...', { session });
saveEmail(stream) saveEmail(stream)
@@ -173,6 +179,10 @@ const smtpServer = new SMTPServer({
}) })
.catch(err => { .catch(err => {
logger.error('Error processing email:', err); logger.error('Error processing email:', err);
// Check if the error is a rate limit error with a specific response code
if (err.responseCode) {
return callback(err);
}
callback(new Error('Failed to process email.')); callback(new Error('Failed to process email.'));
}); });
}, },
@@ -185,3 +195,5 @@ smtpServer.on('error', err => {
smtpServer.listen(smtpPort, () => { smtpServer.listen(smtpPort, () => {
logger.info(`SMTP server listening on port ${smtpPort}`); logger.info(`SMTP server listening on port ${smtpPort}`);
}); });

View File

@@ -0,0 +1,100 @@
const dns = require('dns').promises;
const logger = require('./logger');
const IP_CONNECTION_COUNTS = new Map();
const BANNED_IPS = new Set();
const IP_RATE_LIMIT = 20; // 每个IP(服务器)每分钟最多20次连接
const IP_TIME_WINDOW = 60 * 1000; // 1分钟
const IP_BAN_DURATION = 5 * 60 * 1000; // 5分钟
/**
* 检查IP地址是否因为连接频率过高而被限制。
* @param {string} remoteAddress 客户端IP地址。
* @returns {boolean} 如果被限制则返回true否则返回false。
*/
function isIpRateLimited(remoteAddress) {
if (BANNED_IPS.has(remoteAddress)) {
logger.warn(`Connection from banned IP ${remoteAddress} rejected.`);
return true;
}
const now = Date.now();
const requests = IP_CONNECTION_COUNTS.get(remoteAddress) || [];
const recentRequests = requests.filter(timestamp => now - timestamp < IP_TIME_WINDOW);
if (recentRequests.length >= IP_RATE_LIMIT) {
logger.warn(`IP ${remoteAddress} has exceeded the connection rate limit. Banning for ${IP_BAN_DURATION / 1000} seconds.`);
BANNED_IPS.add(remoteAddress);
setTimeout(() => {
BANNED_IPS.delete(remoteAddress);
logger.info(`IP ${remoteAddress} has been unbanned.`);
}, IP_BAN_DURATION);
IP_CONNECTION_COUNTS.delete(remoteAddress);
return true;
}
recentRequests.push(now);
IP_CONNECTION_COUNTS.set(remoteAddress, recentRequests);
return false;
}
/**
* 检查IP地址是否有有效的反向DNSPTR记录。
* 正规的邮件服务器通常都有PTR记录。
* @param {string} remoteAddress 客户端IP地址。
* @returns {Promise<boolean>} 如果验证通过则返回true否则返回false。
*/
async function hasValidPtrRecord(remoteAddress) {
// 对于本地和私有地址我们跳过检查因为它们通常没有公共PTR记录
if (remoteAddress.startsWith('127.') || remoteAddress.startsWith('192.168.') || remoteAddress.startsWith('10.') || remoteAddress.startsWith('::1')) {
return true;
}
try {
const hostnames = await dns.reverse(remoteAddress);
if (hostnames && hostnames.length > 0) {
logger.info(`PTR record for ${remoteAddress} found: ${hostnames.join(', ')}`);
return true;
}
logger.warn(`No PTR record found for ${remoteAddress}.`);
return false;
} catch (error) {
// 'ENOTFOUND' 是最常见的错误意味着没有找到PTR记录。
if (error.code === 'ENOTFOUND') {
logger.warn(`No PTR record found for ${remoteAddress}.`);
} else {
logger.error(`Error during PTR lookup for ${remoteAddress}:`, error);
}
return false;
}
}
/**
* 在连接建立时验证客户端。
* @param {object} session SMTP会话对象。
* @param {function} callback 回调函数。
*/
async function validateConnection(session, callback) {
const { remoteAddress } = session;
// 1. IP频率限制检查
if (isIpRateLimited(remoteAddress)) {
const err = new Error('Connection rejected due to high frequency. Please try again later.');
err.responseCode = 421;
return callback(err);
}
// 2. 反向DNS检查
const hasPtr = await hasValidPtrRecord(remoteAddress);
if (!hasPtr) {
const err = new Error('Connection rejected: The IP address has no PTR record.');
err.responseCode = 550; // 550表示请求的操作未执行邮箱不可用在这里引申为连接源不可信
return callback(err);
}
// 所有检查通过
callback();
}
module.exports = { validateConnection };

View File

@@ -15,13 +15,31 @@ const promisePool = pool.promise();
const originalExecute = promisePool.execute; const originalExecute = promisePool.execute;
promisePool.execute = function(sql, params) { promisePool.execute = function(sql, params) {
logger.info('Executing SQL', { sql, params }); let loggableParams = params;
// For email insertion, only log recipient and sender to avoid large logs.
if (sql.startsWith('INSERT INTO emails') && Array.isArray(params) && params.length >= 2) {
loggableParams = {
recipient: params[0],
sender: params[1],
details: '(omitted for brevity)'
};
}
logger.info('Executing SQL', { sql, params: loggableParams });
return originalExecute.call(this, sql, params); return originalExecute.call(this, sql, params);
}; };
const originalQuery = promisePool.query; const originalQuery = promisePool.query;
promisePool.query = function(sql, params) { promisePool.query = function(sql, params) {
logger.info('Executing SQL', { sql, params }); let loggableParams = params;
// For email insertion, only log recipient and sender to avoid large logs.
if (sql.startsWith('INSERT INTO emails') && Array.isArray(params) && params.length >= 2) {
loggableParams = {
recipient: params[0],
sender: params[1],
details: '(omitted for brevity)'
};
}
logger.info('Executing SQL', { sql, params: loggableParams });
return originalQuery.call(this, sql, params); return originalQuery.call(this, sql, params);
}; };

View File

@@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS emails (
sender VARCHAR(255) NOT NULL, sender VARCHAR(255) NOT NULL,
subject VARCHAR(512), subject VARCHAR(512),
body TEXT, body TEXT,
received_at DATETIME DEFAULT CURRENT_TIMESTAMP, received_at DATETIME,
raw MEDIUMBLOB raw MEDIUMBLOB
); );

View File

@@ -4,7 +4,8 @@ const logger = winston.createLogger({
level: 'info', level: 'info',
format: winston.format.combine( format: winston.format.combine(
winston.format.timestamp({ winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss' format: 'YYYY-MM-DD HH:mm:ss',
tz: 'Asia/Shanghai'
}), }),
winston.format.errors({ stack: true }), winston.format.errors({ stack: true }),
winston.format.splat(), winston.format.splat(),
@@ -17,11 +18,23 @@ const logger = winston.createLogger({
] ]
}); });
// 在非生产环境下,添加一个带有着色的控制台输出
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({ logger.add(new winston.transports.Console({
format: winston.format.combine( format: winston.format.combine(
winston.format.colorize(), winston.format.colorize(),
winston.format.simple() // 确保控制台日志也有正确格式和时区的时间戳
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss',
tz: 'Asia/Shanghai'
}),
winston.format.printf(({ level, message, timestamp, stack }) => {
if (stack) {
// 打印错误堆栈
return `${timestamp} ${level}: ${message}\n${stack}`;
}
return `${timestamp} ${level}: ${message}`;
})
) )
})); }));
} }

67
backend/rateLimiter.js Normal file
View File

@@ -0,0 +1,67 @@
const logger = require('./logger');
const BANNED_DOMAINS = new Set();
const DOMAIN_REQUEST_COUNTS = new Map();
const RATE_LIMIT = 10; // 每分钟10封邮件
const TIME_WINDOW = 60 * 1000; // 1分钟
const BAN_DURATION = 5 * 60 * 1000; // 5分钟
function getDomainFromEmail(sender) {
if (!sender) {
return null;
}
let emailAddress = sender;
// 检查 "Name <email@domain.com>" 格式
const match = sender.match(/<([^>]+)>/);
if (match && match[1]) {
emailAddress = match[1]; // 提取 'email@domain.com'
}
// 现在,从(可能已清理的)电子邮件地址中提取域名
if (!emailAddress.includes('@')) {
return null;
}
return emailAddress.split('@')[1];
}
function isRateLimited(sender) {
const domain = getDomainFromEmail(sender);
if (!domain) {
// 如果无法从发件人中提取域名,则不进行速率限制
return false;
}
if (BANNED_DOMAINS.has(domain)) {
logger.warn(`Domain ${domain} is currently banned, rejecting email.`, { domain, action: 'reject-banned-domain' });
return true;
}
const now = Date.now();
const requests = DOMAIN_REQUEST_COUNTS.get(domain) || [];
// 过滤掉时间窗口之外的旧请求
const recentRequests = requests.filter(timestamp => now - timestamp < TIME_WINDOW);
if (recentRequests.length >= RATE_LIMIT) {
logger.warn(`Domain ${domain} has exceeded the rate limit. Banning for ${BAN_DURATION / 1000} seconds.`, { domain, action: 'ban-domain' });
BANNED_DOMAINS.add(domain);
// 设置解封计时器
setTimeout(() => {
BANNED_DOMAINS.delete(domain);
logger.info(`Domain ${domain} has been unbanned.`, { domain, action: 'unban-domain' });
}, BAN_DURATION);
// 清空该域名的请求记录
DOMAIN_REQUEST_COUNTS.delete(domain);
return true;
}
// 记录当前请求时间
recentRequests.push(now);
DOMAIN_REQUEST_COUNTS.set(domain, recentRequests);
return false;
}
module.exports = { isRateLimited };

View File

@@ -1,6 +1,8 @@
const { simpleParser } = require('mailparser'); const { simpleParser } = require('mailparser');
const db = require('./db'); const db = require('./db');
const emitter = require('./eventEmitter'); const emitter = require('./eventEmitter');
const { isRateLimited } = require('./rateLimiter');
const logger = require('./logger'); // 引入 logger
// Helper function to convert stream to buffer // Helper function to convert stream to buffer
function streamToBuffer(stream) { function streamToBuffer(stream) {
@@ -19,20 +21,49 @@ async function saveEmail(stream) {
// Now, parse the buffered email content // Now, parse the buffered email content
const parsed = await simpleParser(emailBuffer); const parsed = await simpleParser(emailBuffer);
const rawEmail = emailBuffer.toString();
const recipient = parsed.to ? parsed.to.text : 'undisclosed-recipients'; const recipient = parsed.to ? parsed.to.text : 'undisclosed-recipients';
const sender = parsed.from ? parsed.from.text : 'unknown-sender'; const sender = parsed.from ? parsed.from.text : 'unknown-sender';
//const rawEmail = emailBuffer.toString(); // 暂时去除 rawEmail不在保存到数据库
// 在这里进行速率限制检查
if (isRateLimited(sender)) {
// 记录被拒绝的事件
logger.warn(`Email from <${sender}> rejected due to rate limiting.`, {
sender: sender,
recipient: recipient,
action: 'rate-limit-reject'
});
// 如果被限流则抛出错误上游的SMTPServer会处理这个错误并拒绝邮件
const error = new Error(`4.7.1 Domain of <${sender}> has been temporarily blocked due to rate limiting. Please try again later.`);
error.responseCode = 421; // "Service not available, closing transmission channel"
throw error;
}
const subject = parsed.subject || 'No Subject'; const subject = parsed.subject || 'No Subject';
const body = parsed.text || (parsed.html || ''); const body = parsed.text || (parsed.html || '');
// Manually create a timestamp for 'Asia/Shanghai' timezone
const received_at = new Date().toLocaleString('sv-SE', {
timeZone: 'Asia/Shanghai'
});
const [result] = await db.execute( const [result] = await db.execute(
'INSERT INTO emails (recipient, sender, subject, body, raw) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO emails (recipient, sender, subject, body, received_at) VALUES (?, ?, ?, ?, ?)',
[recipient, sender, subject, body, rawEmail] [recipient, sender, subject, body, received_at]
); );
// const [result] = await db.execute(
// 'INSERT INTO emails (recipient, sender, subject, body, raw) VALUES (?, ?, ?, ?, ?)',
// [recipient, sender, subject, body, rawEmail]
// );
const newEmailId = result.insertId; const newEmailId = result.insertId;
console.log(`Email from <${sender}> to <${recipient}> saved with ID: ${newEmailId}`); logger.info(`Email from <${sender}> to <${recipient}> saved with ID: ${newEmailId}`, {
sender,
recipient,
subject,
emailId: newEmailId,
action: 'email-saved'
});
if (parsed.attachments && parsed.attachments.length > 0) { if (parsed.attachments && parsed.attachments.length > 0) {
for (const attachment of parsed.attachments) { for (const attachment of parsed.attachments) {
@@ -40,7 +71,11 @@ async function saveEmail(stream) {
'INSERT INTO email_attachments (email_id, filename, content_type, content) VALUES (?, ?, ?, ?)', 'INSERT INTO email_attachments (email_id, filename, content_type, content) VALUES (?, ?, ?, ?)',
[newEmailId, attachment.filename, attachment.contentType, attachment.content] [newEmailId, attachment.filename, attachment.contentType, attachment.content]
); );
console.log(`Attachment ${attachment.filename} saved.`); logger.info(`Attachment ${attachment.filename} saved for email ID: ${newEmailId}`, {
filename: attachment.filename,
emailId: newEmailId,
action: 'attachment-saved'
});
} }
} }
@@ -48,12 +83,22 @@ async function saveEmail(stream) {
const [rows] = await db.execute('SELECT id, sender, recipient, subject, body, received_at FROM emails WHERE id = ?', [newEmailId]); const [rows] = await db.execute('SELECT id, sender, recipient, subject, body, received_at FROM emails WHERE id = ?', [newEmailId]);
if (rows.length > 0) { if (rows.length > 0) {
emitter.emit('newEmail', rows[0]); emitter.emit('newEmail', rows[0]);
logger.info(`Event 'newEmail' emitted for email ID: ${newEmailId}`, {
emailId: newEmailId,
action: 'event-emitted'
});
} }
} catch (error) { } catch (error) {
console.error('Failed to save email:', error); // 如果错误是带有响应码的(例如我们的速率限制错误),它已经被记录过了。
// We should not exit the process here, but maybe throw the error // 我们只记录其他意想不到的错误。
// so the caller (SMTPServer) can handle it. if (!error.responseCode) {
logger.error('Failed to save email due to an unexpected error:', {
errorMessage: error.message,
errorStack: error.stack
});
}
// 重新抛出错误以便上游的SMTPServer可以正确处理它。
throw error; throw error;
} }
} }

1
certs/README.md Normal file
View File

@@ -0,0 +1 @@
SSL证书可以放置在这里

View File

@@ -1,12 +1,9 @@
# MySQL/MariaDB Settings # MySQL/MariaDB Settings
MYSQL_ROOT_PASSWORD=123456 MYSQL_ROOT_PASSWORD=123456
MYSQL_DATABASE=maildb MYSQL_DATABASE=maildb
MYSQL_USER=root
MYSQL_PASSWORD=123456
# Backend Database Settings # Backend Database Settings
# These should match the MySQL/MariaDB settings above
DB_HOST=email-mysql DB_HOST=email-mysql
DB_USER=${MYSQL_USER} DB_USER=root
DB_PASSWORD=${MYSQL_PASSWORD} DB_PASSWORD=${MYSQL_ROOT_PASSWORD}
DB_NAME=${MYSQL_DATABASE} DB_NAME=${MYSQL_DATABASE}

View File

@@ -1,12 +1,9 @@
# MySQL/MariaDB Settings # MySQL/MariaDB Settings
MYSQL_ROOT_PASSWORD=your_strong_root_password MYSQL_ROOT_PASSWORD=123456
MYSQL_DATABASE=maildb MYSQL_DATABASE=maildb
MYSQL_USER=root
MYSQL_PASSWORD=root_password
# Backend Database Settings # Backend Database Settings
# These should match the MySQL/MariaDB settings above DB_HOST=email-mysql
DB_HOST=mysql DB_USER=root
DB_USER=${MYSQL_USER} DB_PASSWORD=${MYSQL_ROOT_PASSWORD}
DB_PASSWORD=${MYSQL_PASSWORD}
DB_NAME=${MYSQL_DATABASE} DB_NAME=${MYSQL_DATABASE}

View File

@@ -8,16 +8,22 @@ services:
- compose.env - compose.env
networks: networks:
- email-network - email-network
ports:
- "5182:5182" # API port
- "25:25" # SMTP port
# 2. 数据库服务 (MySQL) # 2. 数据库服务 (MySQL)
mysql: mysql:
image: mysql:8.0 image: mysql:8.0
container_name: email-mysql container_name: email-mysql
restart: always restart: always
environment:
- TZ=Asia/Shanghai
env_file: env_file:
- compose.env - compose.env
volumes: volumes:
- mysql-data:/var/lib/mysql - mysql-data:/var/lib/mysql
- ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks: networks:
- email-network - email-network

View File

@@ -1,46 +1,47 @@
services: services:
# 1. 后端服务 (Node.js + Express + SMTP Server) # 1. 后端服务 (Node.js + Express + SMTP Server)
backend: backend:
build: ./backend image: registry.cn-hangzhou.aliyuncs.com/pull-image/email-unlimit-backend:latest
container_name: email-backend container_name: email-backend
restart: always restart: always
env_file: env_file:
- compose.full.env - compose.full.env
networks: networks:
- email-network - email-network
# 不直接暴露端口给外部,由 Nginx 统一代理 ports:
# ports:
# - "5182:5182" # - "5182:5182"
# - "25:25" - "25:25"
# 2. 数据库服务 (MySQL) # 2. 数据库服务 (MySQL)
mysql: mysql:
image: mysql:8.0 image: registry.cn-hangzhou.aliyuncs.com/pull-image/mysql:8.0
container_name: email-mysql container_name: email-mysql
restart: always restart: always
environment:
- TZ=Asia/Shanghai
env_file: env_file:
- compose.full.env - compose.full.env
volumes: volumes:
- mysql-data:/var/lib/mysql - mysql-data:/var/lib/mysql
- ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks: networks:
- email-network - email-network
# 3. Nginx 反向代理 # 3. Nginx 反向代理
nginx: nginx:
image: nginx:latest image: registry.cn-hangzhou.aliyuncs.com/pull-image/nginx:1.21.6
container_name: email-nginx container_name: email-nginx
restart: always restart: always
ports: ports:
- "7614:80" # HTTP - "80:80" # HTTP
- "443:443" # HTTPS (需要SSL证书) - "443:443" # HTTPS (需要SSL证书)
- "25:25" # SMTP 端口,转发给后端
volumes: volumes:
# 挂载前端构建好的静态文件 # 挂载前端构建好的静态文件
- ./frontend/dist:/usr/share/nginx/html - ./frontend/dist:/usr/share/nginx/html
# 挂载 Nginx 配置文件 # 挂载 Nginx 配置文件
- ./nginx.full.conf:/etc/nginx/nginx.conf:ro - ./nginx.full.conf:/etc/nginx/nginx.conf:ro
# (可选) 挂载 SSL 证书 # 挂载 SSL 证书
# - ./certs:/etc/nginx/certs:ro - ./certs:/etc/nginx/certs:ro
depends_on: depends_on:
- backend - backend
- mysql - mysql

20
docker-compose.self.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
backend:
build:
context: ./backend
args:
HTTP_PROXY: "http://127.0.0.1:7899"
HTTPS_PROXY: "http://127.0.0.1:7899"
container_name: email-backend
restart: always
ports:
- "5182:5182" # API port
- "25:25" # SMTP port
# mysql 使用外部数据源
env_file:
- compose.self.env
networks:
- email-network
networks:
email-network:
driver: bridge

View File

@@ -4,6 +4,9 @@ services:
image: registry.cn-hangzhou.aliyuncs.com/pull-image/email-unlimit-backend:latest image: registry.cn-hangzhou.aliyuncs.com/pull-image/email-unlimit-backend:latest
container_name: email-backend container_name: email-backend
restart: always restart: always
ports:
- "5182:5182" # API port
- "25:25" # SMTP port
env_file: env_file:
- compose.env - compose.env
networks: networks:
@@ -14,10 +17,13 @@ services:
image: registry.cn-hangzhou.aliyuncs.com/pull-image/mysql:8.0 # mysql:8.0 image: registry.cn-hangzhou.aliyuncs.com/pull-image/mysql:8.0 # mysql:8.0
container_name: email-mysql container_name: email-mysql
restart: always restart: always
environment:
- TZ=Asia/Shanghai
env_file: env_file:
- compose.env - compose.env
volumes: volumes:
- mysql-data:/var/lib/mysql - mysql-data:/var/lib/mysql
- ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks: networks:
- email-network - email-network

3
frontend/.env Normal file
View File

@@ -0,0 +1,3 @@
VITE_APP_DOMAIN=shenjianl.cn
VITE_REPO_NAME=Gitea
VITE_REPO_URL=https://gitea.shenjianl.cn/shenjianZ/email-unlimit

View File

@@ -1,10 +1,15 @@
<script setup>
const repoUrl = import.meta.env.VITE_REPO_URL;
const repoName = import.meta.env.VITE_REPO_NAME;
</script>
<template> <template>
<header class="app-header"> <header class="app-header">
<router-link to="/" class="logo-link"> <router-link to="/" class="logo-link">
<div class="logo">Email Unlimit</div> <div class="logo">Email Unlimit</div>
</router-link> </router-link>
<nav class="nav-links"> <nav class="nav-links">
<a href="https://gitea.shenjianl.cn/shenjianZ/email-unlimit" target="_blank">Gitee</a> <a :href="repoUrl" target="_blank">{{ repoName }}</a>
<router-link to="/settings" class="settings-link" title="设置"> <router-link to="/settings" class="settings-link" title="设置">
<img src="@/assets/setting.svg" alt="Settings" class="settings-icon" /> <img src="@/assets/setting.svg" alt="Settings" class="settings-icon" />
</router-link> </router-link>

View File

@@ -24,6 +24,7 @@ const messages = {
selectMail: '请从选择一封邮件查看', selectMail: '请从选择一封邮件查看',
from: '发件人', from: '发件人',
to: '收件人', to: '收件人',
received_at: '收件时间',
attachments: '附件', attachments: '附件',
delete: '删除', delete: '删除',
clearInbox: '清空收件箱', clearInbox: '清空收件箱',
@@ -68,6 +69,7 @@ const messages = {
selectMail: 'Please select an email to view', selectMail: 'Please select an email to view',
from: 'From', from: 'From',
to: 'To', to: 'To',
received_at: 'Received At',
attachments: 'Attachments', attachments: 'Attachments',
delete: 'Delete', delete: 'Delete',
clearInbox: 'Clear Inbox', clearInbox: 'Clear Inbox',

View File

@@ -2,7 +2,7 @@
<main id="app-main"> <main id="app-main">
<section class="hero-section"> <section class="hero-section">
<h1>{{ $t('home.title') }}</h1> <h1>{{ $t('home.title') }}</h1>
<p>{{ $t('home.subtitle') }}<code>@shenjianl.cn</code>{{ $t('home.subtitleAfter') }}</p> <p>{{ $t('home.subtitle') }}<code>@{{ domain }}</code>{{ $t('home.subtitleAfter') }}</p>
<form @submit.prevent="fetchMessages" class="input-group"> <form @submit.prevent="fetchMessages" class="input-group">
<div class="input-wrapper"> <div class="input-wrapper">
<input <input
@@ -64,6 +64,7 @@
<h3>{{ selectedMessage.subject }}</h3> <h3>{{ selectedMessage.subject }}</h3>
<p class="from-line"><strong>{{ $t('home.from') }}:</strong> {{ selectedMessage.sender }}</p> <p class="from-line"><strong>{{ $t('home.from') }}:</strong> {{ selectedMessage.sender }}</p>
<p><strong>{{ $t('home.to') }}:</strong> {{ selectedMessage.recipient }}</p> <p><strong>{{ $t('home.to') }}:</strong> {{ selectedMessage.recipient }}</p>
<p><strong>{{ $t('home.received_at') }}:</strong> {{ selectedMessage.received_at }}</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button @click="deleteMessage(selectedMessage.id)" :title="$t('home.delete')"> <button @click="deleteMessage(selectedMessage.id)" :title="$t('home.delete')">
@@ -100,23 +101,24 @@ const { t } = useI18n();
const recipient = ref(''); const recipient = ref('');
const messages = ref([]); const messages = ref([]);
const selectedMessage = ref(null); const selectedMessage = ref(null);
const domain = import.meta.env.VITE_APP_DOMAIN;
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
selectedMessage.value = { selectedMessage.value = {
id: 'sample-1', id: 'sample-1',
sender: 'demo@example.com', sender: 'demo@example.com',
recipient: 'you@shenjianl.cn', recipient: `you@${domain}`,
subject: 'Markdown 样式测试邮件', subject: 'Markdown 样式测试邮件',
body: `# 会议议程:项目启动会\n\n大家好\n\n这是关于 **“Email-Unlimit”** 项目启动会的议程安排。\n\n---\n\n## 会议详情\n\n- **日期**: 2025年7月30日\n- **时间**: 上午10:00\n- **地点**: 线上会议室 (链接稍后提供)\n\n## 议程\n\n1. **项目介绍** - 介绍项目目标和范围。\n2. **团队分工** - 明确各自的职责。\n3. **技术选型** - 讨论并确认技术栈。\n4. **Q&A** - 自由提问环节。\n\n请准时参加。\n\n谢谢\n\n> 这是一条重要的提醒:请提前准备好您的问题。` body: `# 会议议程:项目启动会\n\n大家好\n\n这是关于 **“Email-Unlimit”** 项目启动会的议程安排。\n\n---\n\n## 会议详情\n\n- **日期**: 2025年7月30日\n- **时间**: 上午10:00\n- **地点**: 线上会议室 (链接稍后提供)\n\n## 议程\n\n1. **项目介绍** - 介绍项目目标和范围。\n2. **团队分工** - 明确各自的职责。\n3. **技术选型** - 讨论并确认技术栈。\n4. **Q&A** - 自由提问环节。\n\n请准时参加。\n\n谢谢\n\n> 这是一条重要的提醒:请提前准备好您的问题。`
}; };
} }
const loading = ref(false); const loading = ref(false);
const copyStatus = ref('idle'); // 'idle' | 'copied' const copyStatus = ref('idle'); // 'idle' | 'copied'
const domain = 'shenjianl.cn';
const attachments = ref([]); const attachments = ref([]);
const attachmentsLoading = ref(false); const attachmentsLoading = ref(false);
const newMailNotification = ref(false); const newMailNotification = ref(false);
const renderedBody = computed(() => { const renderedBody = computed(() => {
if (selectedMessage.value && selectedMessage.value.body) { if (selectedMessage.value && selectedMessage.value.body) {
return marked(selectedMessage.value.body); return marked(selectedMessage.value.body);

View File

@@ -1,3 +1,17 @@
<script setup>
import { useLanguageStore } from '@/stores/language';
import { computed } from 'vue';
const domain = import.meta.env.VITE_APP_DOMAIN;
const languageStore = useLanguageStore();
const setLanguage = (lang) => {
languageStore.setLocale(lang);
};
const currentLocale = computed(() => languageStore.locale);
</script>
<template> <template>
<div class="settings-page"> <div class="settings-page">
<div class="settings-container"> <div class="settings-container">
@@ -5,7 +19,7 @@
<ol> <ol>
<i18n-t keypath="howItWorks.step1" tag="li"> <i18n-t keypath="howItWorks.step1" tag="li">
<template #domain> <template #domain>
<code>@{{ 'shenjianl.cn' }}</code> <code>@{{ domain }}</code>
</template> </template>
</i18n-t> </i18n-t>
<li>{{ $t('howItWorks.step2') }}</li> <li>{{ $t('howItWorks.step2') }}</li>
@@ -22,25 +36,8 @@
</template> </template>
<script> <script>
import { useLanguageStore } from '@/stores/language';
import { computed } from 'vue';
export default { export default {
name: 'Settings', name: 'Settings',
setup() {
const languageStore = useLanguageStore();
const setLanguage = (lang) => {
languageStore.setLocale(lang);
};
const currentLocale = computed(() => languageStore.locale);
return {
setLanguage,
currentLocale,
};
},
}; };
</script> </script>

View File

@@ -1,30 +1,69 @@
events {} worker_processes 1;
events {
worker_connections 1024;
}
http { http {
server { charset utf-8;
listen 7614; include mime.types;
default_type application/octet-stream;
# 启用 SSL 协议,建议加上 TLSv1.3
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
sendfile on;
keepalive_timeout 65;
# HTTP 80 端口,重定向到 HTTPS
server {
listen 80;
server_name mail.shenjianl.cn; # 改为你自己的域名
# 统一重定向到 HTTPS
return 301 https://$host$request_uri;
}
# HTTPS 443 端口主服务
server {
listen 443 ssl;
server_name mail.shenjianl.cn; # 改为你自己的域名
ssl_certificate /etc/nginx/certs/mail.shenjianl.cn_bundle.pem; # 改为你自己的证书文件
ssl_certificate_key /etc/nginx/certs/mail.shenjianl.cn.key; # 改为你自己的密钥文件
# 前端静态文件
location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# 后端 API 转发 # 反向代理 /api 到后端服务
location /api { location /api {
proxy_pass http://backend:5182; proxy_pass http://email-backend:5182;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
}
}
stream { # WebSocket 支持
server { location /ws {
listen 25; proxy_pass http://email-backend:5182;
proxy_pass backend:25; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 设置更长的超时时间以保持 WebSocket 连接
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
} }
} }

BIN
sample.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB