Compare commits
23 Commits
0e267b923c
...
limit-mail
| Author | SHA1 | Date | |
|---|---|---|---|
| c109abc6f5 | |||
| d57037781e | |||
| e936fbc140 | |||
| a96aa6e073 | |||
| 893fac1ff2 | |||
| be59fa85de | |||
| 3ad3849fde | |||
| 99504e53ea | |||
| 4787c9e2e0 | |||
| f8a4fb3561 | |||
| eecac11240 | |||
| 9ceb96d9c7 | |||
| 5b7cc39496 | |||
| a7d8774157 | |||
| 91b3bc9640 | |||
| 2b84fffacb | |||
| d9a744dda4 | |||
| f39b1f0246 | |||
| 90ec3e5fc5 | |||
| 1f01ff97ad | |||
| 5e89bff405 | |||
| a681494b82 | |||
| 2994a48e19 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
21
LICENSE
Normal 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.
|
||||||
156
README.md
156
README.md
@@ -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>
|
||||||
|
|
||||||
|
**网站截图**:
|
||||||
|

|
||||||
|
|
||||||
## 技术架构
|
## 技术架构
|
||||||
|
|
||||||
* **前端 (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
|
### 步骤 4: 修改 Nginx 配置 (服务器)
|
||||||
|
编辑项目根目录下的 `nginx.full.conf` 文件,仅需修改以下三项:
|
||||||
|
```nginx
|
||||||
|
server_name mail.example.com; # 改为您自己的域名
|
||||||
|
ssl_certificate /etc/nginx/certs/your_certificate.pem; # 改为您自己的证书文件名
|
||||||
|
ssl_certificate_key /etc/nginx/certs/your_certificate.key; # 改为您自己的密钥文件名
|
||||||
|
```
|
||||||
|
> **注意**: 配置文件中的证书路径 `/etc/nginx/certs/` 是容器内的路径,它会映射到宿主机的 `/data/email-unlimit/certs` 目录,请确保文件名正确。
|
||||||
|
|
||||||
1. 在服务器上,为您的应用创建一个 Nginx 配置文件,例如 `/etc/nginx/sites-available/email.conf`。
|
### 步骤 5: 启动服务 (服务器)
|
||||||
2. 将以下配置写入该文件。请务必将 `your_domain.com` 和 `root` 路径修改为您自己的配置。
|
在服务器的项目根目录 (`/data/email-unlimit`) 下执行以下命令:
|
||||||
|
```bash
|
||||||
```nginx
|
# -f 指定使用 docker-compose.full.yml 配置文件,-d 表示后台运行
|
||||||
server {
|
docker compose -f docker-compose.full.yml up -d
|
||||||
listen 443 ssl;
|
```
|
||||||
server_name mail.shenjianl.cn; # 替换为您的域名
|
服务启动后,您可以通过 `docker compose -f docker-compose.full.yml logs -f` 查看实时日志。
|
||||||
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:
|
|
||||||
```bash
|
|
||||||
# 创建软链接
|
|
||||||
sudo ln -s /etc/nginx/sites-available/email.conf /etc/nginx/sites-enabled/
|
|
||||||
|
|
||||||
# 测试配置语法
|
|
||||||
sudo nginx -t
|
|
||||||
|
|
||||||
# 重启 Nginx
|
|
||||||
sudo systemctl restart nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
## 如何使用
|
## 如何使用
|
||||||
|
|
||||||
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) 文件。
|
||||||
|
|||||||
@@ -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 ./
|
||||||
|
|||||||
76
backend/SECURITY_POLICIES.md
Normal file
76
backend/SECURITY_POLICIES.md
Normal 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是否拥有一个有效的反向DNS(PTR)记录。这是区分正规邮件服务器和僵尸网络/垃圾邮件程序的有效手段。
|
||||||
|
- **策略**:
|
||||||
|
- 所有连接到本服务的公网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 分钟) |
|
||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
100
backend/connectionValidator.js
Normal file
100
backend/connectionValidator.js
Normal 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地址是否有有效的反向DNS(PTR)记录。
|
||||||
|
* 正规的邮件服务器通常都有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 };
|
||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
67
backend/rateLimiter.js
Normal 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 };
|
||||||
@@ -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
1
certs/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
SSL证书可以放置在这里
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
20
docker-compose.self.yml
Normal 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
|
||||||
@@ -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
3
frontend/.env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_APP_DOMAIN=shenjianl.cn
|
||||||
|
VITE_REPO_NAME=Gitea
|
||||||
|
VITE_REPO_URL=https://gitea.shenjianl.cn/shenjianZ/email-unlimit
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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; # 改为你自己的密钥文件
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
# 前端静态文件
|
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
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
BIN
sample.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Reference in New Issue
Block a user