Compare commits
33 Commits
mailu
...
893fac1ff2
| Author | SHA1 | Date | |
|---|---|---|---|
| 893fac1ff2 | |||
| be59fa85de | |||
| 3ad3849fde | |||
| 99504e53ea | |||
| 4787c9e2e0 | |||
| f8a4fb3561 | |||
| eecac11240 | |||
| 9ceb96d9c7 | |||
| 5b7cc39496 | |||
| a7d8774157 | |||
| 91b3bc9640 | |||
| 2b84fffacb | |||
| d9a744dda4 | |||
| f39b1f0246 | |||
| 90ec3e5fc5 | |||
| 1f01ff97ad | |||
| 5e89bff405 | |||
| a681494b82 | |||
| 2994a48e19 | |||
| 0e267b923c | |||
| 4b00824463 | |||
| eb5150dc15 | |||
| 7ee074249f | |||
| 32320e1cb0 | |||
| 8c649adf93 | |||
| c38a4f0f62 | |||
| a186439b84 | |||
| 7bfb909532 | |||
| d85b531e24 | |||
| 59d0ae2a88 | |||
| 82659c600a | |||
| f1b39d22a5 | |||
| eeb3b4b0df |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,4 +1,9 @@
|
|||||||
/backend/node_modules
|
/backend/node_modules
|
||||||
/frontend/node_modules
|
/frontend/node_modules
|
||||||
/backend/package-lock.json
|
/backend/package-lock.json
|
||||||
/frontend/package-lock.json
|
/frontend/package-lock.json
|
||||||
|
/frontend/dist
|
||||||
|
plan.md
|
||||||
|
info.md
|
||||||
|
# docker-compose.self.yml
|
||||||
|
compose.self.env
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
1.在终端执行命令时使用适合Windows 的命令
|
1.在终端执行命令时使用适合Windows的命令,不要使用linux的命令
|
||||||
|
|
||||||
2.始终使用中文回答
|
2.始终使用中文回答
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
150
README.md
150
README.md
@@ -1,95 +1,95 @@
|
|||||||
# Email Unlimit Project (临时邮件项目)
|
# Email Unlimit - 轻量级、可自托管的临时邮件解决方案
|
||||||
|
|
||||||
## 最终架构 (单一 Docker Compose + 宿主机 Nginx)
|
本项目是一个轻量级的、可自托管的临时邮件解决方案。它允许您使用自己的域名接收邮件,并通过一个简洁的网页界面来查看这些邮件。
|
||||||
|
|
||||||
本项目采用生产环境推荐的架构:所有服务(包括您的应用和完整的 Mailu 邮件套件)都由一个 `docker-compose.full.yml` 文件统一管理,而入口的反向代理则由 **宿主机上的 Nginx** 负责。
|
与 `Mailu` 等复杂的邮件套件不同,本项目采用了一个极简的 Node.js 服务来直接接收和处理邮件,部署和维护都非常简单。
|
||||||
|
|
||||||
- **宿主机 Nginx:** 作为项目的唯一公共入口 (`main.shenjianl.cn`)。它负责:
|
**在线体验:** <a href="https://mail.shenjianl.cn" target="_blank">https://mail.shenjianl.cn</a>
|
||||||
1. 处理所有外部流量和 SSL 加密(推荐)。
|
|
||||||
2. 将对域名根路径 (`/`) 的访问反向代理到 **前端 Docker 容器** (`localhost:5181`)。
|
|
||||||
3. 将对 `/api` 路径的访问反向代理到 **后端 Docker 容器** (`localhost:5182`)。
|
|
||||||
4. 将对 `/mailu` 路径的访问反向代理到 **Mailu Admin UI** (`localhost:80`)。
|
|
||||||
|
|
||||||
- **Docker 容器 (由 `docker-compose.full.yml` 管理):**
|
**网站截图**:
|
||||||
- **您的应用:** `frontend`, `backend`, `mysql`。
|

|
||||||
- **Mailu 套件:** `front` (Mailu Nginx), `admin`, `smtp`, `imap`, `redis` 等全套服务。
|
|
||||||
|
|
||||||
---
|
## 技术架构
|
||||||
|
|
||||||
## 如何运行
|
* **前端 (Frontend)**: 使用 Vue.js 构建的单页面应用,负责展示收到的邮件列表。
|
||||||
|
* **后端 (Backend)**:
|
||||||
|
* 使用 Node.js 和 Express 搭建的 API 服务器。
|
||||||
|
* 内置一个轻量级的 SMTP 服务器 (`smtp-server`),用于直接接收邮件,无需外部邮件服务。
|
||||||
|
* 通过 WebSocket 实现新邮件的实时推送。
|
||||||
|
* **数据库 (Database)**: 使用 MySQL 存储邮件信息,确保数据的安全性和持久性。
|
||||||
|
* **部署 (Deployment)**: 使用 Docker Compose 统一管理后端服务、数据库和 Nginx,实现一键部署。
|
||||||
|
|
||||||
### 步骤 1: 准备工作
|
## 前置条件
|
||||||
|
|
||||||
1. **安装 Docker**: 确保您的系统中已安装 Docker 和 Docker Compose。
|
在开始之前,请确保您已满足以下条件:
|
||||||
2. **配置 Mailu**: 打开 `mailu.env` 文件,**务必修改** `SECRET_KEY` 和 `ADMIN_PASSWORD` 为安全的值。域名和数据库配置已预填。
|
|
||||||
3. **配置 DNS**: 根据 `info.md` 中的 DNS 记录示例,在您的域名提供商处完成 SPF、DKIM 和 DMARC 的配置。
|
|
||||||
|
|
||||||
### 步骤 2: 配置宿主机 Nginx
|
### 1. 硬件与域名
|
||||||
|
- 一台拥有公网 IP 的云服务器。
|
||||||
|
- 一个您自己的域名(建议已完成备案)。
|
||||||
|
|
||||||
将下面的配置块添加到您宿主机的 Nginx 中。这份配置统一处理了对您的应用和 Mailu 管理后台的访问。
|
### 2. 软件环境
|
||||||
|
- 服务器上已安装 [Docker 和 Docker Compose](https://docs.docker.com/get-docker/)。
|
||||||
|
- 本地开发环境已安装 [Node.js](https://nodejs.org/) (仅用于构建前端)。
|
||||||
|
|
||||||
|
### 3. 域名 DNS 配置
|
||||||
|
要让邮件能正确发送到您的服务器,您必须配置域名的 `A` 记录和 `MX` 记录。假设您的域名是 `example.com`,您希望用于收信的子域名是 `mail.example.com`。
|
||||||
|
|
||||||
|
| 类型 | 主机记录/名称 | 记录值/指向 | 备注 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| A | mail | `您的服务器公网 IP` | 将 `mail` 子域名指向您的服务器 |
|
||||||
|
| MX | @ | `mail.example.com` | 优先级设置为 10 |
|
||||||
|
|
||||||
|
> **注意**: DNS 记录生效可能需要几分钟到几小时。
|
||||||
|
|
||||||
|
### 4. 申请 SSL/TLS 证书
|
||||||
|
为了启用 HTTPS,您需要为您的域名 `mail.example.com` 申请 SSL 证书。您可以从 Let's Encrypt、阿里云、腾讯云等证书颁发机构免费获取。
|
||||||
|
|
||||||
|
## 部署步骤
|
||||||
|
|
||||||
|
本部署方案使用 Docker Compose 统一管理所有服务(Nginx, Backend, MySQL),实现一键启动,无需在宿主机上安装 Nginx 或其他依赖。
|
||||||
|
|
||||||
|
### 步骤 1: 获取并准备项目
|
||||||
|
将项目代码下载并解压到您的服务器,例如 `/data/email-unlimit` 目录。
|
||||||
|
|
||||||
|
### 步骤 2: 上传 SSL 证书
|
||||||
|
将您申请到的 SSL 证书文件(通常是 `.pem`/.`crt` 和 `.key` 文件)上传到项目的 `certs` 目录下 (`/data/email-unlimit/certs`)。
|
||||||
|
|
||||||
|
### 步骤 3: 构建前端静态文件 (本地环境)
|
||||||
|
1. 在您的**本地开发机**上,进入 `frontend` 目录,修改 `.env` 环境变量文件:
|
||||||
|
```env
|
||||||
|
VITE_APP_DOMAIN=example.com # 改为你的主域名
|
||||||
|
```
|
||||||
|
2. 安装依赖并构建:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
3. 将构建生成的 `dist` 目录(位于 `frontend/dist`)**完整上传**到服务器的 `/data/email-unlimit/frontend` 目录下。
|
||||||
|
|
||||||
|
### 步骤 4: 修改 Nginx 配置 (服务器)
|
||||||
|
编辑项目根目录下的 `nginx.full.conf` 文件,仅需修改以下三项:
|
||||||
```nginx
|
```nginx
|
||||||
# /etc/nginx/conf.d/main.shenjianl.cn.conf
|
server_name mail.example.com; # 改为您自己的域名
|
||||||
|
ssl_certificate /etc/nginx/certs/your_certificate.pem; # 改为您自己的证书文件名
|
||||||
server {
|
ssl_certificate_key /etc/nginx/certs/your_certificate.key; # 改为您自己的密钥文件名
|
||||||
listen 80;
|
|
||||||
server_name main.shenjianl.cn;
|
|
||||||
|
|
||||||
# --- 推荐配置 SSL ---
|
|
||||||
# listen 443 ssl http2;
|
|
||||||
# server_name main.shenjianl.cn;
|
|
||||||
# ssl_certificate /path/to/your/fullchain.pem;
|
|
||||||
# ssl_certificate_key /path/to/your/privkey.pem;
|
|
||||||
# --------------------
|
|
||||||
|
|
||||||
# 反向代理到前端 Vue.js 容器
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:5181;
|
|
||||||
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;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
}
|
|
||||||
|
|
||||||
# 反向代理到后端 API 容器
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 反向代理到 Mailu Admin UI 和 Webmail
|
|
||||||
# 注意:Mailu 自己的 Nginx (front) 在 80 端口上运行
|
|
||||||
location /mailu {
|
|
||||||
proxy_pass http://localhost:80;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
**配置完成后,请重载或重启您的 Nginx 服务。**
|
> **注意**: 配置文件中的证书路径 `/etc/nginx/certs/` 是容器内的路径,它会映射到宿主机的 `/data/email-unlimit/certs` 目录,请确保文件名正确。
|
||||||
|
|
||||||
### 步骤 3: 启动 Docker 服务
|
|
||||||
|
|
||||||
使用我们最终生成的 `docker-compose.full.yml` 文件来启动所有服务。
|
|
||||||
|
|
||||||
|
### 步骤 5: 启动服务 (服务器)
|
||||||
|
在服务器的项目根目录 (`/data/email-unlimit`) 下执行以下命令:
|
||||||
```bash
|
```bash
|
||||||
# 在项目根目录执行
|
# -f 指定使用 docker-compose.full.yml 配置文件,-d 表示后台运行
|
||||||
docker-compose -f docker-compose.full.yml up -d --build
|
docker compose -f docker-compose.full.yml up -d
|
||||||
```
|
```
|
||||||
|
服务启动后,您可以通过 `docker compose -f docker-compose.full.yml logs -f` 查看实时日志。
|
||||||
|
|
||||||
---
|
## 如何使用
|
||||||
|
|
||||||
## 访问<E8AEBF><E997AE><EFBFBD>址
|
1. **访问您的网站**: 在浏览器中打开 `https://mail.example.com` (替换为您的域名)。
|
||||||
|
2. **发送测试邮件**: 使用任何邮箱客户端,向 `anything@example.com` (例如 `test@example.com`) 发送一封邮件。
|
||||||
|
3. **查看邮件**: 在网站首页的输入框中输入您刚刚使用的收件人地址 (`test@example.com`),点击查询,即可看到收到的邮件。
|
||||||
|
|
||||||
- **主应用入口**: `http://main.shenjianl.cn`
|
## 许可证
|
||||||
- **Mailu 管理后台**: `http://main.shenjianl.cn/mailu`
|
|
||||||
- **Mailu Webmail**: `http://main.shenjianl.cn/mailu/webmail`
|
|
||||||
|
|
||||||
docker-compose up -d --build
|
本项目采用 MIT 许可证。详情请参阅 [LICENSE](LICENSE) 文件。
|
||||||
|
|||||||
@@ -3,10 +3,22 @@ FROM node:20-alpine
|
|||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
│
|
||||||
|
# Set timezone to Asia/Shanghai │
|
||||||
|
RUN apk add --no-cache tzdata │
|
||||||
|
ENV TZ=Asia/Shanghai │
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Expose API port and SMTP port
|
||||||
EXPOSE 5182
|
EXPOSE 5182
|
||||||
|
EXPOSE 25
|
||||||
|
|
||||||
|
# Environment variables for database connection will be passed at runtime
|
||||||
|
# ENV DB_HOST=...
|
||||||
|
# ENV DB_USER=...
|
||||||
|
# ENV DB_PASSWORD=...
|
||||||
|
# ENV DB_NAME=...
|
||||||
|
|
||||||
CMD [ "node", "app.js" ]
|
CMD [ "node", "app.js" ]
|
||||||
|
|||||||
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 分钟) |
|
||||||
179
backend/app.js
179
backend/app.js
@@ -1,9 +1,21 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const http = require('http');
|
||||||
|
const { WebSocketServer } = require('ws');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
|
const { SMTPServer } = require('smtp-server');
|
||||||
|
const { saveEmail } = require('./saveEmail');
|
||||||
|
const emitter = require('./eventEmitter');
|
||||||
|
const logger = require('./logger');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const { validateConnection } = require('./connectionValidator');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 5182;
|
const apiPort = 5182;
|
||||||
|
const smtpPort = 25;
|
||||||
|
|
||||||
|
// Setup morgan to log HTTP requests to winston
|
||||||
|
app.use(morgan('combined', { stream: { write: message => logger.info(message.trim()) } }));
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -12,21 +24,176 @@ app.use(express.json());
|
|||||||
app.get('/api/messages', async (req, res) => {
|
app.get('/api/messages', async (req, res) => {
|
||||||
const { recipient } = req.query;
|
const { recipient } = req.query;
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
|
logger.warn('Attempted to get messages without a recipient');
|
||||||
return res.status(400).send('Recipient is required');
|
return res.status(400).send('Recipient is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.execute(
|
const [rows] = await db.execute(
|
||||||
'SELECT id, sender, subject, body, received_at FROM emails WHERE recipient = ? ORDER BY received_at DESC',
|
'SELECT id, sender, recipient, subject, body, received_at FROM emails WHERE recipient LIKE ? ORDER BY received_at DESC',
|
||||||
[recipient]
|
[`%${recipient}%`]
|
||||||
);
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch emails:', error);
|
logger.error('Failed to fetch emails:', error);
|
||||||
res.status(500).send('Failed to fetch emails');
|
res.status(500).send('Failed to fetch emails');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(port, () => {
|
// API to get attachments for a message
|
||||||
console.log(`Backend server listening at http://localhost:${port}`);
|
app.get('/api/messages/:id/attachments', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const [rows] = await db.execute(
|
||||||
|
'SELECT id, filename, content_type FROM email_attachments WHERE email_id = ?',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch attachments:', { error, emailId: id });
|
||||||
|
res.status(500).send('Failed to fetch attachments');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API to download an attachment
|
||||||
|
app.get('/api/attachments/:attachmentId', async (req, res) => {
|
||||||
|
const { attachmentId } = req.params;
|
||||||
|
try {
|
||||||
|
const [rows] = await db.execute(
|
||||||
|
'SELECT filename, content_type, content FROM email_attachments WHERE id = ?',
|
||||||
|
[attachmentId]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return res.status(404).send('Attachment not found');
|
||||||
|
}
|
||||||
|
const attachment = rows[0];
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${attachment.filename}"`);
|
||||||
|
res.setHeader('Content-Type', attachment.content_type);
|
||||||
|
res.send(attachment.content);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to download attachment:', { error, attachmentId });
|
||||||
|
res.status(500).send('Failed to download attachment');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API to delete a message
|
||||||
|
app.delete('/api/messages/:id', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const [result] = await db.execute('DELETE FROM emails WHERE id = ?', [id]);
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
logger.warn('Attempted to delete a message that was not found', { id });
|
||||||
|
return res.status(404).send('Message not found');
|
||||||
|
}
|
||||||
|
logger.info('Message deleted successfully', { id });
|
||||||
|
res.status(204).send(); // No Content
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete message:', { error, id });
|
||||||
|
res.status(500).send('Failed to delete message');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API to delete all messages for a recipient
|
||||||
|
app.delete('/api/messages', async (req, res) => {
|
||||||
|
const { recipient } = req.query;
|
||||||
|
if (!recipient) {
|
||||||
|
logger.warn('Attempted to delete all messages without a recipient');
|
||||||
|
return res.status(400).send('Recipient is required');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await db.execute('DELETE FROM emails WHERE recipient LIKE ?', [`%${recipient}%`]);
|
||||||
|
logger.info('All messages for recipient deleted successfully', { recipient });
|
||||||
|
res.status(204).send(); // No Content
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete all messages:', { error, recipient });
|
||||||
|
res.status(500).send('Failed to delete all messages');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create HTTP server
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
// Create WebSocket server
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
// Map to store connections for each recipient
|
||||||
|
const clients = new Map();
|
||||||
|
|
||||||
|
wss.on('connection', (ws, req) => {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||||
|
const recipient = url.searchParams.get('recipient');
|
||||||
|
|
||||||
|
if (recipient) {
|
||||||
|
logger.info(`WebSocket client connected`, { recipient });
|
||||||
|
clients.set(recipient, ws);
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
logger.info(`WebSocket client disconnected`, { recipient });
|
||||||
|
clients.delete(recipient);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (error) => {
|
||||||
|
logger.error(`WebSocket error`, { recipient, error });
|
||||||
|
clients.delete(recipient);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn('WebSocket client connected without recipient, closing.');
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
emitter.on('newEmail', (email) => {
|
||||||
|
const dbRecipient = email.recipient; // 比如 "6471" <6471@shenjianl.cn>
|
||||||
|
|
||||||
|
// 遍历所有 clients,用 includes 判断
|
||||||
|
for (const [clientRecipient, ws] of clients.entries()) {
|
||||||
|
if (dbRecipient.includes(clientRecipient)) {
|
||||||
|
logger.info(`Sending new email notification to`, { recipient: clientRecipient });
|
||||||
|
ws.send(JSON.stringify(email));
|
||||||
|
break; // 如果只想发给第一个匹配的就 break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Start API server
|
||||||
|
server.listen(apiPort, () => {
|
||||||
|
logger.info(`Backend API and WebSocket server listening at http://localhost:${apiPort}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure and start SMTP server
|
||||||
|
const smtpServer = new SMTPServer({
|
||||||
|
secure: false, // Enable STARTTLS
|
||||||
|
authOptional: true,
|
||||||
|
disabledCommands: ['AUTH'],
|
||||||
|
onConnect(session, callback) {
|
||||||
|
logger.info('Connection received from', { remoteAddress: session.remoteAddress });
|
||||||
|
return validateConnection(session, callback);
|
||||||
|
},
|
||||||
|
onData(stream, session, callback) {
|
||||||
|
logger.info('Receiving email...', { session });
|
||||||
|
saveEmail(stream)
|
||||||
|
.then(() => {
|
||||||
|
logger.info('Email processed and saved successfully.');
|
||||||
|
callback(); // Accept the message
|
||||||
|
})
|
||||||
|
.catch(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.'));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
smtpServer.on('error', err => {
|
||||||
|
logger.error('SMTP Server Error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
smtpServer.listen(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 };
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const mysql = require('mysql2');
|
const mysql = require('mysql2');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
const pool = mysql.createPool({
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
@@ -10,4 +11,36 @@ const pool = mysql.createPool({
|
|||||||
queueLimit: 0
|
queueLimit: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = pool.promise();
|
const promisePool = pool.promise();
|
||||||
|
|
||||||
|
const originalExecute = promisePool.execute;
|
||||||
|
promisePool.execute = function(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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalQuery = promisePool.query;
|
||||||
|
promisePool.query = function(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);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = promisePool;
|
||||||
|
|||||||
4
backend/eventEmitter.js
Normal file
4
backend/eventEmitter.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
const EventEmitter = require('events');
|
||||||
|
class MyEmitter extends EventEmitter {}
|
||||||
|
const emitter = new MyEmitter();
|
||||||
|
module.exports = emitter;
|
||||||
45
backend/logger.js
Normal file
45
backend/logger.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const winston = require('winston');
|
||||||
|
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: 'info',
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp({
|
||||||
|
format: 'YYYY-MM-DD HH:mm:ss'
|
||||||
|
}),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.splat(),
|
||||||
|
winston.format.json()
|
||||||
|
),
|
||||||
|
defaultMeta: { service: 'user-service' },
|
||||||
|
transports: [
|
||||||
|
new winston.transports.File({ filename: 'error.log', level: 'error' }),
|
||||||
|
new winston.transports.File({ filename: 'combined.log' })
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
logger.add(new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.printf(({ level, message, timestamp, stack }) => {
|
||||||
|
if (stack) {
|
||||||
|
// 打印错误堆栈
|
||||||
|
return `${timestamp} ${level}: ${message}\n${stack}`;
|
||||||
|
}
|
||||||
|
return `${timestamp} ${level}: ${message}`;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在文件日志中也使用更清晰的格式
|
||||||
|
logger.transports.forEach(t => {
|
||||||
|
if (t.name === 'file') {
|
||||||
|
t.format = winston.format.combine(
|
||||||
|
winston.format.timestamp(),
|
||||||
|
winston.format.json()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
"mysql2": "^3.14.2",
|
"mysql2": "^3.14.2",
|
||||||
"mailparser": "^3.7.4",
|
"mailparser": "^3.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"get-stream": "^9.0.1"
|
"smtp-server": "^3.13.4",
|
||||||
|
"ws": "^8.17.1",
|
||||||
|
"winston": "^3.13.0",
|
||||||
|
"morgan": "^1.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,54 +1,102 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
const { simpleParser } = require('mailparser');
|
const { simpleParser } = require('mailparser');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
|
const emitter = require('./eventEmitter');
|
||||||
|
const { isRateLimited } = require('./rateLimiter');
|
||||||
|
const logger = require('./logger'); // 引入 logger
|
||||||
|
|
||||||
// Using require for get-stream v5 as it's CommonJS
|
// Helper function to convert stream to buffer
|
||||||
const getStream = require('get-stream');
|
function streamToBuffer(stream) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
stream.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
stream.on('error', reject);
|
||||||
|
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function saveEmail(rawEmail) {
|
async function saveEmail(stream) {
|
||||||
try {
|
try {
|
||||||
const parsed = await simpleParser(rawEmail);
|
// First, buffer the entire stream
|
||||||
|
const emailBuffer = await streamToBuffer(stream);
|
||||||
|
|
||||||
|
// Now, parse the buffered email content
|
||||||
|
const parsed = await simpleParser(emailBuffer);
|
||||||
|
|
||||||
// Ensure 'to' and 'from' objects exist before accessing 'text'
|
|
||||||
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 subject = parsed.subject;
|
//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 body = parsed.text || (parsed.html || '');
|
const body = parsed.text || (parsed.html || '');
|
||||||
|
|
||||||
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) VALUES (?, ?, ?, ?)',
|
||||||
[recipient, sender, subject, body, rawEmail]
|
[recipient, sender, subject, body]
|
||||||
);
|
);
|
||||||
|
// const [result] = await db.execute(
|
||||||
|
// 'INSERT INTO emails (recipient, sender, subject, body, raw) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
// [recipient, sender, subject, body, rawEmail]
|
||||||
|
// );
|
||||||
|
const newEmailId = result.insertId;
|
||||||
|
|
||||||
console.log(`Email saved with ID: ${result.insertId}`);
|
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) {
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'INSERT INTO email_attachments (email_id, filename, content_type, content) VALUES (?, ?, ?, ?)',
|
'INSERT INTO email_attachments (email_id, filename, content_type, content) VALUES (?, ?, ?, ?)',
|
||||||
[result.insertId, 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'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit an event with the new email's main information
|
||||||
|
const [rows] = await db.execute('SELECT id, sender, recipient, subject, body, received_at FROM emails WHERE id = ?', [newEmailId]);
|
||||||
|
if (rows.length > 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);
|
// 如果错误是带有响应码的(例如我们的速率限制错误),它已经被记录过了。
|
||||||
// Exit with an error code to signal failure to the calling process (e.g., Mailu)
|
// 我们只记录其他意想不到的错误。
|
||||||
process.exit(1);
|
if (!error.responseCode) {
|
||||||
|
logger.error('Failed to save email due to an unexpected error:', {
|
||||||
|
errorMessage: error.message,
|
||||||
|
errorStack: error.stack
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 重新抛出错误,以便上游的SMTPServer可以正确处理它。
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
module.exports = { saveEmail };
|
||||||
try {
|
|
||||||
const rawEmail = await getStream(process.stdin);
|
|
||||||
if (rawEmail && rawEmail.length > 0) {
|
|
||||||
await saveEmail(rawEmail);
|
|
||||||
} else {
|
|
||||||
console.log('Received empty input, no email to save.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading from stdin:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
1
certs/README.md
Normal file
1
certs/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
SSL证书可以放置在这里
|
||||||
9
compose.env
Normal file
9
compose.env
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# MySQL/MariaDB Settings
|
||||||
|
MYSQL_ROOT_PASSWORD=123456
|
||||||
|
MYSQL_DATABASE=maildb
|
||||||
|
|
||||||
|
# Backend Database Settings
|
||||||
|
DB_HOST=email-mysql
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASSWORD=${MYSQL_ROOT_PASSWORD}
|
||||||
|
DB_NAME=${MYSQL_DATABASE}
|
||||||
9
compose.full.env
Normal file
9
compose.full.env
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# MySQL/MariaDB Settings
|
||||||
|
MYSQL_ROOT_PASSWORD=123456
|
||||||
|
MYSQL_DATABASE=maildb
|
||||||
|
|
||||||
|
# Backend Database Settings
|
||||||
|
DB_HOST=email-mysql
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASSWORD=${MYSQL_ROOT_PASSWORD}
|
||||||
|
DB_NAME=${MYSQL_DATABASE}
|
||||||
33
docker-compose.build.yml
Normal file
33
docker-compose.build.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
# 1. 后端服务 (Node.js + Express + SMTP Server)
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: email-backend
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- compose.env
|
||||||
|
networks:
|
||||||
|
- email-network
|
||||||
|
ports:
|
||||||
|
- "5182:5182" # API port
|
||||||
|
- "25:25" # SMTP port
|
||||||
|
|
||||||
|
# 2. 数据库服务 (MySQL)
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: email-mysql
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- compose.env
|
||||||
|
volumes:
|
||||||
|
- mysql-data:/var/lib/mysql
|
||||||
|
- ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
networks:
|
||||||
|
- email-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
email-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql-data: {}
|
||||||
@@ -1,118 +1,54 @@
|
|||||||
# docker-compose.full.yml
|
|
||||||
# 最终合并版本:包含您的应用 (frontend, backend) 和完整的 Mailu 服务套件。
|
|
||||||
|
|
||||||
version: '3.3'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# 1. 后端服务 (Node.js + Express + SMTP Server)
|
||||||
# -----------------------------------------
|
|
||||||
# 您的应用服务
|
|
||||||
# -----------------------------------------
|
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
image: registry.cn-hangzhou.aliyuncs.com/pull-image/email-unlimit-backend:latest
|
||||||
container_name: mail_backend
|
container_name: email-backend
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
env_file:
|
||||||
DB_HOST: "43.143.145.172"
|
- compose.full.env
|
||||||
DB_USER: "root"
|
networks:
|
||||||
DB_PASSWORD: "kyff145972"
|
- email-network
|
||||||
DB_NAME: "maildb"
|
|
||||||
ports:
|
ports:
|
||||||
- "5182:5182"
|
# - "5182:5182"
|
||||||
|
- "25:25"
|
||||||
|
|
||||||
frontend:
|
# 2. 数据库服务 (MySQL)
|
||||||
build: ./frontend
|
mysql:
|
||||||
container_name: mail_frontend
|
image: registry.cn-hangzhou.aliyuncs.com/pull-image/mysql:8.0
|
||||||
|
container_name: email-mysql
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- compose.full.env
|
||||||
|
volumes:
|
||||||
|
- mysql-data:/var/lib/mysql
|
||||||
|
- ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
networks:
|
||||||
|
- email-network
|
||||||
|
|
||||||
|
# 3. Nginx 反向代理
|
||||||
|
nginx:
|
||||||
|
image: registry.cn-hangzhou.aliyuncs.com/pull-image/nginx:1.21.6
|
||||||
|
container_name: email-nginx
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "5181:8080"
|
- "80:80" # HTTP
|
||||||
|
- "443:443" # HTTPS (需要SSL证书)
|
||||||
|
volumes:
|
||||||
|
# 挂载前端构建好的静态文件
|
||||||
|
- ./frontend/dist:/usr/share/nginx/html
|
||||||
|
# 挂载 Nginx 配置文件
|
||||||
|
- ./nginx.full.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
# 挂载 SSL 证书
|
||||||
|
- ./certs:/etc/nginx/certs:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
- mysql
|
||||||
|
networks:
|
||||||
|
- email-network
|
||||||
|
|
||||||
# -----------------------------------------
|
networks:
|
||||||
# Mailu 官方服务套件
|
email-network:
|
||||||
# -----------------------------------------
|
driver: bridge
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:alpine
|
|
||||||
restart: always
|
|
||||||
volumes:
|
|
||||||
- "mailu_redis:/data"
|
|
||||||
|
|
||||||
front:
|
|
||||||
image: ghcr.io/mailu/nginx:2.0
|
|
||||||
restart: always
|
|
||||||
env_file: mailu.env
|
|
||||||
hostname: mail.shenjianl.cn
|
|
||||||
volumes:
|
|
||||||
- "mailu_certs:/certs"
|
|
||||||
- "mailu_overrides_nginx:/overrides"
|
|
||||||
|
|
||||||
resolver:
|
|
||||||
image: ghcr.io/mailu/unbound:2.0
|
|
||||||
restart: always
|
|
||||||
env_file: mailu.env
|
|
||||||
hostname: mail.shenjianl.cn
|
|
||||||
dns:
|
|
||||||
- 8.8.8.8
|
|
||||||
- 1.1.1.1
|
|
||||||
# --- 用于调试的临时修改 ---
|
|
||||||
entrypoint: /bin/sh
|
|
||||||
command: -c "sleep 3600"
|
|
||||||
|
|
||||||
admin:
|
|
||||||
image: ghcr.io/mailu/admin:2.0
|
|
||||||
restart: always
|
|
||||||
env_file: mailu.env
|
|
||||||
hostname: mail.shenjianl.cn
|
|
||||||
volumes:
|
|
||||||
- "mailu_data:/data"
|
|
||||||
- "mailu_dkim:/dkim"
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
|
|
||||||
imap:
|
|
||||||
image: ghcr.io/mailu/dovecot:2.0
|
|
||||||
restart: always
|
|
||||||
env_file: mailu.env
|
|
||||||
hostname: mail.shenjianl.cn
|
|
||||||
volumes:
|
|
||||||
- "mailu_mail:/mail"
|
|
||||||
- "mailu_overrides_dovecot:/overrides"
|
|
||||||
depends_on:
|
|
||||||
- front
|
|
||||||
|
|
||||||
smtp:
|
|
||||||
image: ghcr.io/mailu/postfix:2.0
|
|
||||||
restart: always
|
|
||||||
env_file: mailu.env
|
|
||||||
hostname: mail.shenjianl.cn
|
|
||||||
volumes:
|
|
||||||
- "mailu_overrides_postfix:/overrides"
|
|
||||||
depends_on:
|
|
||||||
- front
|
|
||||||
- resolver
|
|
||||||
|
|
||||||
antispam:
|
|
||||||
image: ghcr.io/mailu/rspamd:2.0
|
|
||||||
restart: always
|
|
||||||
env_file: mailu.env
|
|
||||||
hostname: mail.shenjianl.cn
|
|
||||||
volumes:
|
|
||||||
- "mailu_filter:/var/lib/rspamd"
|
|
||||||
- "mailu_overrides_rspamd:/etc/rspamd/override.d"
|
|
||||||
depends_on:
|
|
||||||
- front
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mailu_data:
|
mysql-data: {}
|
||||||
mailu_certs:
|
|
||||||
mailu_dkim:
|
|
||||||
mailu_filter:
|
|
||||||
mailu_mail:
|
|
||||||
mailu_redis:
|
|
||||||
mailu_overrides_nginx:
|
|
||||||
mailu_overrides_dovecot:
|
|
||||||
mailu_overrides_postfix:
|
|
||||||
mailu_overrides_rspamd:
|
|
||||||
16
docker-compose.self.yml
Normal file
16
docker-compose.self.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
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
|
||||||
@@ -1,115 +1,33 @@
|
|||||||
# docker-compose.full.yml
|
|
||||||
# 最终合并版本:包含您的应用 (frontend, backend) 和完整的 Mailu 服务套件。
|
|
||||||
|
|
||||||
version: '3.3'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# 1. 后端服务 (Node.js + Express + SMTP Server)
|
||||||
# -----------------------------------------
|
|
||||||
# 您的应用服务
|
|
||||||
# -----------------------------------------
|
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
image: registry.cn-hangzhou.aliyuncs.com/pull-image/email-unlimit-backend:latest
|
||||||
container_name: mail_backend
|
container_name: email-backend
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
DB_HOST: "43.143.145.172"
|
|
||||||
DB_USER: "root"
|
|
||||||
DB_PASSWORD: "kyff145972"
|
|
||||||
DB_NAME: "maildb"
|
|
||||||
ports:
|
|
||||||
- "5182:5182"
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build: ./frontend
|
|
||||||
container_name: mail_frontend
|
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "5181:8080"
|
- "5182:5182" # API port
|
||||||
depends_on:
|
- "25:25" # SMTP port
|
||||||
- backend
|
env_file:
|
||||||
|
- compose.env
|
||||||
|
networks:
|
||||||
|
- email-network
|
||||||
|
|
||||||
# -----------------------------------------
|
# 2. 数据库服务 (MySQL)
|
||||||
# Mailu 官方服务套件
|
mysql:
|
||||||
# -----------------------------------------
|
image: registry.cn-hangzhou.aliyuncs.com/pull-image/mysql:8.0 # mysql:8.0
|
||||||
|
container_name: email-mysql
|
||||||
redis:
|
|
||||||
image: redis:alpine
|
|
||||||
restart: always
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- compose.env
|
||||||
volumes:
|
volumes:
|
||||||
- "mailu_redis:/data"
|
- mysql-data:/var/lib/mysql
|
||||||
|
- ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
networks:
|
||||||
|
- email-network
|
||||||
|
|
||||||
front:
|
networks:
|
||||||
image: ghcr.io/mailu/nginx:2.0
|
email-network:
|
||||||
restart: always
|
driver: bridge
|
||||||
env_file: mailu.env
|
|
||||||
hostname: mail.shenjianl.cn
|
|
||||||
volumes:
|
|
||||||
- "mailu_certs:/certs"
|
|
||||||
- "mailu_overrides_nginx:/overrides"
|
|
||||||
|
|
||||||
resolver:
|
|
||||||
image: ghcr.io/mailu/unbound:2.0
|
|
||||||
restart: always
|
|
||||||
env_file: mailu.env
|
|
||||||
hostname: mail.shenjianl.cn
|
|
||||||
dns:
|
|
||||||
- 8.8.8.8
|
|
||||||
- 1.1.1.1
|
|
||||||
|
|
||||||
admin:
|
|
||||||
image: ghcr.io/mailu/admin:2.0
|
|
||||||
restart: always
|
|
||||||
env_file: mailu.env
|
|
||||||
hostname: mail.shenjianl.cn
|
|
||||||
volumes:
|
|
||||||
- "mailu_data:/data"
|
|
||||||
- "mailu_dkim:/dkim"
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
|
|
||||||
imap:
|
|
||||||
image: ghcr.io/mailu/dovecot:2.0
|
|
||||||
restart: always
|
|
||||||
env_file: mailu.env
|
|
||||||
hostname: mail.shenjianl.cn
|
|
||||||
volumes:
|
|
||||||
- "mailu_mail:/mail"
|
|
||||||
- "mailu_overrides_dovecot:/overrides"
|
|
||||||
depends_on:
|
|
||||||
- front
|
|
||||||
|
|
||||||
smtp:
|
|
||||||
image: ghcr.io/mailu/postfix:2.0
|
|
||||||
restart: always
|
|
||||||
env_file: mailu.env
|
|
||||||
hostname: mail.shenjianl.cn
|
|
||||||
volumes:
|
|
||||||
- "mailu_overrides_postfix:/overrides"
|
|
||||||
depends_on:
|
|
||||||
- front
|
|
||||||
- resolver
|
|
||||||
|
|
||||||
antispam:
|
|
||||||
image: ghcr.io/mailu/rspamd:2.0
|
|
||||||
restart: always
|
|
||||||
env_file: mailu.env
|
|
||||||
hostname: mail.shenjianl.cn
|
|
||||||
volumes:
|
|
||||||
- "mailu_filter:/var/lib/rspamd"
|
|
||||||
- "mailu_overrides_rspamd:/etc/rspamd/override.d"
|
|
||||||
depends_on:
|
|
||||||
- front
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mailu_data:
|
mysql-data: {}
|
||||||
mailu_certs:
|
|
||||||
mailu_dkim:
|
|
||||||
mailu_filter:
|
|
||||||
mailu_mail:
|
|
||||||
mailu_redis:
|
|
||||||
mailu_overrides_nginx:
|
|
||||||
mailu_overrides_dovecot:
|
|
||||||
mailu_overrides_postfix:
|
|
||||||
mailu_overrides_rspamd:
|
|
||||||
|
|||||||
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
|
||||||
@@ -4,7 +4,11 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
<link rel="icon" href="/logo.svg" type="image/svg+xml">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<meta name="theme-color" content="#000000">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png">
|
||||||
<title>临时邮件</title>
|
<title>临时邮件</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -12,6 +16,6 @@
|
|||||||
<strong>We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
<strong>We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||||
</noscript>
|
</noscript>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<!-- built files will be auto injected -->
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -3,14 +3,24 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "vue-cli-service serve",
|
"dev": "vite",
|
||||||
"build": "vue-cli-service build"
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"element-plus": "^2.10.4",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"axios": "^1.11.0"
|
"vue-i18n": "^11.1.11",
|
||||||
|
"vue-router": "^4.5.1",
|
||||||
|
"ws": "^8.18.3",
|
||||||
|
"marked": "^13.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vue/cli-service": "~5.0.8"
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
|
"vite": "^5.3.1",
|
||||||
|
"vite-plugin-pwa": "^0.20.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
frontend/public/icons/icon.svg
Normal file
1
frontend/public/icons/icon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1753688160727" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3424" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M972.8 934.274509 998.4 908.54902 127.868001 908.54902C85.502225 908.54902 51.2 874.019706 51.2 831.263017L51.2 111.058823 25.6 136.784314 896.131998 136.784314C938.452011 136.784314 972.8 171.324278 972.8 213.860934L972.8 278.566694C972.8 292.77449 984.26151 304.292183 998.4 304.292183 1012.53849 304.292183 1024 292.77449 1024 278.566694L1024 213.860934C1024 142.916556 966.736828 85.333333 896.131998 85.333333L25.6 85.333333 0 85.333333 0 111.058823 0 831.263017C0 902.415639 57.205646 960 127.868001 960L998.4 960 1024 960 1024 934.274509 1024 457.69849C1024 443.490694 1012.53849 431.973001 998.4 431.973001 984.26151 431.973001 972.8 443.490694 972.8 457.69849L972.8 934.274509ZM512.651558 567.164817C520.64791 572.874498 531.187889 573.490406 539.788919 568.750601L1002.624789 313.693694C1015.021598 306.862133 1019.560139 291.225196 1012.761901 278.767619 1005.963665 266.310041 990.403006 261.749254 978.006197 268.580816L515.170327 523.637722 542.307689 525.223505 194.028065 276.539467C182.502788 268.310009 166.520953 271.027598 158.331639 282.609372 150.142325 294.191146 152.846657 310.251322 164.371935 318.480781L512.651558 567.164817Z" fill="#000000" p-id="3425"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
1
frontend/public/logo.svg
Normal file
1
frontend/public/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1753688160727" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3424" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M972.8 934.274509 998.4 908.54902 127.868001 908.54902C85.502225 908.54902 51.2 874.019706 51.2 831.263017L51.2 111.058823 25.6 136.784314 896.131998 136.784314C938.452011 136.784314 972.8 171.324278 972.8 213.860934L972.8 278.566694C972.8 292.77449 984.26151 304.292183 998.4 304.292183 1012.53849 304.292183 1024 292.77449 1024 278.566694L1024 213.860934C1024 142.916556 966.736828 85.333333 896.131998 85.333333L25.6 85.333333 0 85.333333 0 111.058823 0 831.263017C0 902.415639 57.205646 960 127.868001 960L998.4 960 1024 960 1024 934.274509 1024 457.69849C1024 443.490694 1012.53849 431.973001 998.4 431.973001 984.26151 431.973001 972.8 443.490694 972.8 457.69849L972.8 934.274509ZM512.651558 567.164817C520.64791 572.874498 531.187889 573.490406 539.788919 568.750601L1002.624789 313.693694C1015.021598 306.862133 1019.560139 291.225196 1012.761901 278.767619 1005.963665 266.310041 990.403006 261.749254 978.006197 268.580816L515.170327 523.637722 542.307689 525.223505 194.028065 276.539467C182.502788 268.310009 166.520953 271.027598 158.331639 282.609372 150.142325 294.191146 152.846657 310.251322 164.371935 318.480781L512.651558 567.164817Z" fill="#000000" p-id="3425"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
15
frontend/public/manifest.json
Normal file
15
frontend/public/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "Email Unlimit",
|
||||||
|
"short_name": "EmailUnlimit",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#000000"
|
||||||
|
}
|
||||||
2
frontend/public/robots.txt
Normal file
2
frontend/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -1,257 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app-container">
|
<AppHeader />
|
||||||
<h1>临时邮箱</h1>
|
<router-view />
|
||||||
<div class="email-generator">
|
|
||||||
<input v-model="randomEmail" type="text" readonly @click="copyToClipboard" title="点击复制"/>
|
|
||||||
<button @click="generateRandomEmail">生成新地址</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inbox">
|
|
||||||
<h2>收件箱 ({{ currentEmail }})</h2>
|
|
||||||
<button @click="fetchMessages" :disabled="loading">
|
|
||||||
{{ loading ? '刷新中...' : '刷新邮件' }}
|
|
||||||
</button>
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
|
||||||
<ul v-if="messages.length > 0" class="message-list">
|
|
||||||
<li v-for="msg in messages" :key="msg.id" @click="selectMessage(msg)" :class="{ selected: selectedMessage && selectedMessage.id === msg.id }">
|
|
||||||
<div class="sender"><strong>发件人:</strong> {{ msg.sender }}</div>
|
|
||||||
<div class="subject"><strong>主题:</strong> {{ msg.subject }}</div>
|
|
||||||
<div class="time">{{ new Date(msg.received_at).toLocaleString() }}</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div v-else class="no-messages">
|
|
||||||
<p>{{ loading ? '正在加载...' : '收件箱是空的。' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="selectedMessage" class="message-view">
|
|
||||||
<h3>{{ selectedMessage.subject }}</h3>
|
|
||||||
<p><strong>发件人:</strong> {{ selectedMessage.sender }}</p>
|
|
||||||
<p><strong>收件人:</strong> {{ currentEmail }}</p>
|
|
||||||
<p><strong>时间:</strong> {{ new Date(selectedMessage.received_at).toLocaleString() }}</p>
|
|
||||||
<hr>
|
|
||||||
<div v-html="selectedMessage.body" class="email-body"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import axios from 'axios';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import AppHeader from './components/AppHeader.vue';
|
||||||
const API_URL = '/api';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'App',
|
name: 'App',
|
||||||
data() {
|
components: {
|
||||||
return {
|
AppHeader,
|
||||||
randomEmail: '',
|
|
||||||
currentEmail: '',
|
|
||||||
messages: [],
|
|
||||||
selectedMessage: null,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
domain: 'shenjianl.cn', // 从 info.md 获取
|
|
||||||
refreshInterval: null
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
methods: {
|
setup() {
|
||||||
generateRandomString(length) {
|
// 在根组件中建立 i18n 上下文,以供所有子路由继承
|
||||||
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
useI18n();
|
||||||
let result = '';
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
result += characters.charAt(Math.floor(Math.random() * characters.length));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
generateRandomEmail() {
|
|
||||||
const name = this.generateRandomString(8);
|
|
||||||
this.randomEmail = `${name}@${this.domain}`;
|
|
||||||
this.currentEmail = this.randomEmail;
|
|
||||||
this.messages = [];
|
|
||||||
this.selectedMessage = null;
|
|
||||||
this.fetchMessages();
|
|
||||||
},
|
|
||||||
async fetchMessages() {
|
|
||||||
if (!this.currentEmail) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.loading = true;
|
|
||||||
this.error = null;
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${API_URL}/messages`, {
|
|
||||||
params: { recipient: this.currentEmail }
|
|
||||||
});
|
|
||||||
this.messages = response.data;
|
|
||||||
// 如果有新邮件,且当前选中了邮件,则更新选中的邮件内容
|
|
||||||
if (this.selectedMessage) {
|
|
||||||
const updatedSelected = this.messages.find(m => m.id === this.selectedMessage.id);
|
|
||||||
if (updatedSelected) {
|
|
||||||
this.selectedMessage = updatedSelected;
|
|
||||||
} else {
|
|
||||||
this.selectedMessage = null; // 如果邮件被删除,则取消选中
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.error = '无法加载邮件。请检查后端服务是否运行。';
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selectMessage(message) {
|
|
||||||
this.selectedMessage = message;
|
|
||||||
},
|
|
||||||
copyToClipboard() {
|
|
||||||
navigator.clipboard.writeText(this.randomEmail).then(() => {
|
|
||||||
alert('邮箱地址已复制到剪贴板');
|
|
||||||
}, (err) => {
|
|
||||||
console.error('Could not copy text: ', err);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
startAutoRefresh() {
|
|
||||||
this.refreshInterval = setInterval(this.fetchMessages, 15000); // 每15秒刷新一次
|
|
||||||
},
|
|
||||||
stopAutoRefresh() {
|
|
||||||
if (this.refreshInterval) {
|
|
||||||
clearInterval(this.refreshInterval);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
created() {
|
|
||||||
this.generateRandomEmail();
|
|
||||||
this.startAutoRefresh();
|
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
this.stopAutoRefresh();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
background-color: #f4f7f9;
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app-container {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: #fff;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3 {
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-generator {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-generator input {
|
|
||||||
flex-grow: 1;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 16px;
|
|
||||||
background: #f9f9f9;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 10px 15px;
|
|
||||||
border: none;
|
|
||||||
background-color: #42b983;
|
|
||||||
color: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
background-color: #36a374;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
background-color: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inbox {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
border-radius: 4px;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-list li {
|
|
||||||
padding: 15px;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-list li:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-list li:hover {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-list li.selected {
|
|
||||||
background-color: #e8f5e9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sender, .subject, .time {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: #777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-messages {
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
color: #777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-view {
|
|
||||||
border: 1px solid #eee;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #fdfdfd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-body {
|
|
||||||
margin-top: 15px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
background-color: #fff;
|
|
||||||
padding: 1em;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #e74c3c;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
1
frontend/src/assets/logo.svg
Normal file
1
frontend/src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1753688160727" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3424" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M972.8 934.274509 998.4 908.54902 127.868001 908.54902C85.502225 908.54902 51.2 874.019706 51.2 831.263017L51.2 111.058823 25.6 136.784314 896.131998 136.784314C938.452011 136.784314 972.8 171.324278 972.8 213.860934L972.8 278.566694C972.8 292.77449 984.26151 304.292183 998.4 304.292183 1012.53849 304.292183 1024 292.77449 1024 278.566694L1024 213.860934C1024 142.916556 966.736828 85.333333 896.131998 85.333333L25.6 85.333333 0 85.333333 0 111.058823 0 831.263017C0 902.415639 57.205646 960 127.868001 960L998.4 960 1024 960 1024 934.274509 1024 457.69849C1024 443.490694 1012.53849 431.973001 998.4 431.973001 984.26151 431.973001 972.8 443.490694 972.8 457.69849L972.8 934.274509ZM512.651558 567.164817C520.64791 572.874498 531.187889 573.490406 539.788919 568.750601L1002.624789 313.693694C1015.021598 306.862133 1019.560139 291.225196 1012.761901 278.767619 1005.963665 266.310041 990.403006 261.749254 978.006197 268.580816L515.170327 523.637722 542.307689 525.223505 194.028065 276.539467C182.502788 268.310009 166.520953 271.027598 158.331639 282.609372 150.142325 294.191146 152.846657 310.251322 164.371935 318.480781L512.651558 567.164817Z" fill="#000000" p-id="3425"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
500
frontend/src/assets/main.css
Normal file
500
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap');
|
||||||
|
/* Global Styles & Resets */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-purple: #6d28d9;
|
||||||
|
--light-purple: #8b5cf6;
|
||||||
|
--dark-purple: #5b21b6;
|
||||||
|
--light-grey: #f3f4f6;
|
||||||
|
--medium-grey: #e5e7eb;
|
||||||
|
--dark-grey: #4b5563;
|
||||||
|
--text-light: #ffffff;
|
||||||
|
--text-dark: #1f2937;
|
||||||
|
--border-radius: 12px;
|
||||||
|
--card-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: var(--text-dark);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
overflow-x: hidden; /* Prevent horizontal scrolling */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App Container */
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-main {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1400px;
|
||||||
|
padding: 0 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.app-header {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1400px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--dark-grey);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--primary-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero Section */
|
||||||
|
.hero-section {
|
||||||
|
width: 100%;
|
||||||
|
/* max-width is handled by app-main */
|
||||||
|
background-color: var(--primary-purple);
|
||||||
|
color: var(--text-light);
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section p {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-input {
|
||||||
|
flex: 1;
|
||||||
|
/* top, right, bottom, left */
|
||||||
|
padding: 0.75rem 3rem 0.75rem 1rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0.5rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--dark-grey);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:hover {
|
||||||
|
background-color: var(--medium-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy span {
|
||||||
|
color: var(--primary-purple);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--light-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--light-purple);
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--dark-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #ef4444; /* a nice red */
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #dc2626; /* a darker red */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inbox Section */
|
||||||
|
.inbox-section {
|
||||||
|
width: 100%;
|
||||||
|
/* max-width is handled by app-main */
|
||||||
|
background-color: var(--light-grey);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
width: 35%;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--dark-grey);
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions button:hover:not(:disabled) {
|
||||||
|
background-color: var(--medium-grey);
|
||||||
|
color: var(--primary-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions button:disabled {
|
||||||
|
color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .el-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item:hover {
|
||||||
|
background-color: var(--medium-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item.selected {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-left-color: var(--primary-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item .from {
|
||||||
|
font-weight: 700;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item .subject {
|
||||||
|
color: var(--dark-grey);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-detail {
|
||||||
|
width: 65%;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state, .loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--dark-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content-header h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--dark-grey);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: var(--primary-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--dark-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content code {
|
||||||
|
background-color: var(--light-purple);
|
||||||
|
color: var(--text-light);
|
||||||
|
padding: 0.3em 0.6em;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--dark-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
-webkit-text-size-adjust: 100%; /* Prevent iOS font scaling */
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-main {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
padding: 1rem; /* Adjust padding for mobile */
|
||||||
|
/* Keep flex row layout for alignment */
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
margin-left: 0; /* Reset margin for mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
margin: 0 0 0 1rem; /* Space out the links */
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-align: center; /* Ensure content is centered on mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section p {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-input {
|
||||||
|
max-width: none; /* Remove max-width on mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-section {
|
||||||
|
min-width: 0; /* Reset min-width */
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-container {
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: auto;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list,
|
||||||
|
.message-detail {
|
||||||
|
width: 100%;
|
||||||
|
border-right: none;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
border-bottom: 1px solid var(--medium-grey);
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-detail .empty-state {
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
height: auto; /* Adjust height for mobile */
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
width: 95%;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 方案二优化版:现代渐变风格 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px; /* 保持一个舒适的宽度 */
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--light-grey); /* 轨道颜色与背景融为一体 */
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(45deg, var(--light-purple), var(--primary-purple)); /* 调整渐变角度 */
|
||||||
|
border-radius: 4px; /* 圆角调整为一半的宽度 */
|
||||||
|
border: 2px solid var(--light-grey); /* 使用轨道颜色作为边框,营造内边距效果 */
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: linear-gradient(45deg, var(--primary-purple), var(--dark-purple)); /* 悬停时加深渐变 */
|
||||||
|
}
|
||||||
1
frontend/src/assets/setting.svg
Normal file
1
frontend/src/assets/setting.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg t="1753714773086" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2330" width="64" height="64"><path d="M400.896 261.156571l20.041143-100.205714A18.285714 18.285714 0 0 1 438.857143 146.285714h146.285714a18.285714 18.285714 0 0 1 17.92 14.701715l20.041143 100.169142c17.846857 7.899429 34.779429 17.700571 50.541714 29.220572l96.804572-32.768a18.285714 18.285714 0 0 1 21.686857 8.192l73.142857 126.683428a18.285714 18.285714 0 0 1-3.766857 22.893715l-76.763429 67.474285a277.211429 277.211429 0 0 1 0 58.294858l76.8 67.474285a18.285714 18.285714 0 0 1 3.730286 22.893715l-73.142857 126.683428a18.285714 18.285714 0 0 1-21.686857 8.192l-96.804572-32.768c-15.762286 11.52-32.694857 21.321143-50.541714 29.220572l-20.041143 100.205714A18.285714 18.285714 0 0 1 585.142857 877.714286h-146.285714a18.285714 18.285714 0 0 1-17.92-14.701715l-20.041143-100.169142a273.993143 273.993143 0 0 1-50.541714-29.220572l-96.804572 32.768a18.285714 18.285714 0 0 1-21.686857-8.192l-73.142857-126.683428a18.285714 18.285714 0 0 1 3.766857-22.893715l76.763429-67.474285a277.211429 277.211429 0 0 1 0-58.294858l-76.8-67.474285a18.285714 18.285714 0 0 1-3.730286-22.893715l73.142857-126.683428a18.285714 18.285714 0 0 1 21.686857-8.192l96.804572 32.768c15.762286-11.52 32.694857-21.321143 50.541714-29.220572zM603.428571 512a91.428571 91.428571 0 1 0-182.857142 0 91.428571 91.428571 0 0 0 182.857142 0z m36.571429 0a128 128 0 1 1-256 0 128 128 0 0 1 256 0z m-205.165714-234.166857a18.285714 18.285714 0 0 1-11.117715 13.385143 237.421714 237.421714 0 0 0-58.697142 33.938285 18.285714 18.285714 0 0 1-17.188572 2.962286l-91.794286-31.085714-58.148571 100.754286 72.777143 63.963428a18.285714 18.285714 0 0 1 6.034286 16.310857 239.908571 239.908571 0 0 0 0 67.876572 18.285714 18.285714 0 0 1-6.034286 16.310857l-72.777143 63.963428L256 726.930286l91.794286-31.085715a18.285714 18.285714 0 0 1 17.188571 2.998858 237.421714 237.421714 0 0 0 58.697143 33.938285 18.285714 18.285714 0 0 1 11.154286 13.385143L453.851429 841.142857h116.297142l19.017143-94.976a18.285714 18.285714 0 0 1 11.117715-13.385143 237.421714 237.421714 0 0 0 58.697142-33.938285 18.285714 18.285714 0 0 1 17.188572-2.962286l91.794286 31.085714 58.148571-100.754286-72.777143-63.963428a18.285714 18.285714 0 0 1-6.034286-16.310857 239.908571 239.908571 0 0 0 0-67.876572 18.285714 18.285714 0 0 1 6.034286-16.310857l72.777143-63.963428L768 297.069714l-91.794286 31.085715a18.285714 18.285714 0 0 1-17.188571-2.998858 237.421714 237.421714 0 0 0-58.697143-33.938285 18.285714 18.285714 0 0 1-11.154286-13.385143L570.148571 182.857143h-116.297142l-19.017143 94.976z" fill="#000000" p-id="2331"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
53
frontend/src/components/AppHeader.vue
Normal file
53
frontend/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup>
|
||||||
|
const repoUrl = import.meta.env.VITE_REPO_URL;
|
||||||
|
const repoName = import.meta.env.VITE_REPO_NAME;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="app-header">
|
||||||
|
<router-link to="/" class="logo-link">
|
||||||
|
<div class="logo">Email Unlimit</div>
|
||||||
|
</router-link>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a :href="repoUrl" target="_blank">{{ repoName }}</a>
|
||||||
|
<router-link to="/settings" class="settings-link" title="设置">
|
||||||
|
<img src="@/assets/setting.svg" alt="Settings" class="settings-icon" />
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'AppHeader',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.logo-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
transition: filter 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-link:hover .settings-icon {
|
||||||
|
/* Generated by https://codepen.io/sosuke/pen/Pjoqqp */
|
||||||
|
/* This filter converts black to the --primary-purple color */
|
||||||
|
filter: brightness(0) saturate(100%) invert(20%) sepia(80%) saturate(3750%) hue-rotate(261deg) brightness(95%) contrast(94%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
101
frontend/src/i18n/index.js
Normal file
101
frontend/src/i18n/index.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { createI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
zh: {
|
||||||
|
howItWorks: {
|
||||||
|
title: '工作原理',
|
||||||
|
step1: '在上面的输入框中,随意编造一个以 {domain} 结尾的邮箱地址。',
|
||||||
|
step2: '使用这个地址去注册任何网站或接收邮件。',
|
||||||
|
step3: '在这里输入您刚刚使用的地址,点击“查看收件箱”,即可看到邮件。',
|
||||||
|
},
|
||||||
|
language: '语言',
|
||||||
|
home: {
|
||||||
|
title: '您的专属临时邮箱,无限且私密',
|
||||||
|
subtitle: '输入任何',
|
||||||
|
subtitleAfter: '地址,立即在此查看收件箱。',
|
||||||
|
placeholder: '输入您的临时邮箱地址...',
|
||||||
|
copyTitle: '复制地址',
|
||||||
|
random: '随机生成',
|
||||||
|
checkInbox: '查看收件箱',
|
||||||
|
inbox: '收件箱',
|
||||||
|
refresh: '刷新',
|
||||||
|
loading: '正在加载...',
|
||||||
|
noMail: '暂无邮件',
|
||||||
|
selectMail: '请从选择一封邮件查看',
|
||||||
|
from: '发件人',
|
||||||
|
to: '收件人',
|
||||||
|
attachments: '附件',
|
||||||
|
delete: '删除',
|
||||||
|
clearInbox: '清空收件箱',
|
||||||
|
alerts: {
|
||||||
|
enterEmail: '请输入一个邮箱地址',
|
||||||
|
fetchFailed: '无法获取邮件,请检查后端服务和 Nginx 配置是否正常。',
|
||||||
|
confirmDelete: '确定要删除这封邮件吗?',
|
||||||
|
deleteFailed: '删除邮件失败。',
|
||||||
|
confirmClearAll: '确定要清空这个收件箱的所有邮件吗?此操作不可恢复。',
|
||||||
|
clearFailed: '清空收件箱失败。',
|
||||||
|
generateRandomSuccess: '已生成随机邮箱地址',
|
||||||
|
checkInboxStart: '正在查询收件箱',
|
||||||
|
copyFailed: '复制失败!',
|
||||||
|
copySuccess: '复制成功!', // <-- 这里添加
|
||||||
|
actionCancelled: '操作已取消',
|
||||||
|
deleteSuccess: '删除成功!',
|
||||||
|
clearSuccess: '收件箱已清空!',
|
||||||
|
},
|
||||||
|
newMailNotification: '收到新邮件!',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
howItWorks: {
|
||||||
|
title: 'How it Works',
|
||||||
|
step1: 'Invent any email address ending in {domain} in the input box above.',
|
||||||
|
step2: 'Use this address to register on any website or receive emails.',
|
||||||
|
step3: 'Enter the address you just used here and click "Check Inbox" to see the emails.',
|
||||||
|
},
|
||||||
|
language: 'Language',
|
||||||
|
home: {
|
||||||
|
title: 'Your Exclusive, Unlimited, and Private Temporary Email',
|
||||||
|
subtitle: 'Enter any address ending in ',
|
||||||
|
subtitleAfter: ' to check the inbox right here.',
|
||||||
|
placeholder: 'Enter your temporary email address...',
|
||||||
|
copyTitle: 'Copy Address',
|
||||||
|
random: 'Generate Random',
|
||||||
|
checkInbox: 'Check Inbox',
|
||||||
|
inbox: 'Inbox',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
loading: 'Loading...',
|
||||||
|
noMail: 'No mail yet',
|
||||||
|
selectMail: 'Please select an email to view',
|
||||||
|
from: 'From',
|
||||||
|
to: 'To',
|
||||||
|
attachments: 'Attachments',
|
||||||
|
delete: 'Delete',
|
||||||
|
clearInbox: 'Clear Inbox',
|
||||||
|
alerts: {
|
||||||
|
enterEmail: 'Please enter an email address',
|
||||||
|
fetchFailed: 'Failed to fetch emails. Please check if the backend service and Nginx configuration are normal.',
|
||||||
|
confirmDelete: 'Are you sure you want to delete this email?',
|
||||||
|
deleteFailed: 'Failed to delete email.',
|
||||||
|
confirmClearAll: 'Are you sure you want to clear all emails in this inbox? This action cannot be undone.',
|
||||||
|
clearFailed: 'Failed to clear inbox.',
|
||||||
|
generateRandomSuccess: 'Random email address generated',
|
||||||
|
checkInboxStart: 'Checking inbox',
|
||||||
|
copyFailed: 'Copy failed!',
|
||||||
|
copySuccess: 'Copied successfully!', // <-- 这里添加
|
||||||
|
actionCancelled: 'Action cancelled',
|
||||||
|
deleteSuccess: 'Deleted successfully!',
|
||||||
|
clearSuccess: 'Inbox cleared!',
|
||||||
|
},
|
||||||
|
newMailNotification: 'New mail received!',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false, // 使用组合式 API
|
||||||
|
locale: 'zh', // 设置默认语言
|
||||||
|
fallbackLocale: 'en', // 设置回退语言
|
||||||
|
messages, // 设置语言环境信息
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -1,4 +1,32 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp, watch } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import i18n from './i18n'
|
||||||
|
import { useLanguageStore } from './stores/language'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
const app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(router)
|
||||||
|
app.use(i18n)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
|
||||||
|
const languageStore = useLanguageStore(pinia)
|
||||||
|
|
||||||
|
// 监听 Pinia store 中的 locale 变化,并更新 i18n
|
||||||
|
watch(
|
||||||
|
() => languageStore.locale,
|
||||||
|
(newLocale) => {
|
||||||
|
i18n.global.locale.value = newLocale
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 初始化时,确保 i18n 的 locale 与 store 同步
|
||||||
|
i18n.global.locale.value = languageStore.locale
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
|
|||||||
23
frontend/src/router/index.js
Normal file
23
frontend/src/router/index.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import Home from '../views/Home.vue';
|
||||||
|
import Settings from '../views/Settings.vue';
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: Home,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'Settings',
|
||||||
|
component: Settings,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(process.env.BASE_URL),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
17
frontend/src/stores/language.js
Normal file
17
frontend/src/stores/language.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'user_language';
|
||||||
|
|
||||||
|
export const useLanguageStore = defineStore('language', {
|
||||||
|
state: () => ({
|
||||||
|
// 优先从 localStorage 获取,否则默认为中文
|
||||||
|
locale: localStorage.getItem(STORAGE_KEY) || 'zh',
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
setLocale(newLocale) {
|
||||||
|
this.locale = newLocale;
|
||||||
|
// 将新设置存入 localStorage
|
||||||
|
localStorage.setItem(STORAGE_KEY, newLocale);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
11
frontend/src/utils/gravatar.js
Normal file
11
frontend/src/utils/gravatar.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { md5 } from './md5.js';
|
||||||
|
|
||||||
|
function get_gravatar(email, size = 80, defaultImage = 'retro') {
|
||||||
|
if (!email) {
|
||||||
|
return `https://www.gravatar.com/avatar/?s=${size}&d=${defaultImage}`;
|
||||||
|
}
|
||||||
|
const emailHash = md5(email.trim().toLowerCase());
|
||||||
|
return `https://www.gravatar.com/avatar/${emailHash}?s=${size}&d=${defaultImage}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { get_gravatar };
|
||||||
219
frontend/src/utils/md5.js
Normal file
219
frontend/src/utils/md5.js
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/*
|
||||||
|
* A JavaScript implementation of the RSA Data Security, Inc. MD5 Message
|
||||||
|
* Digest Algorithm, as defined in RFC 1321.
|
||||||
|
* Version 2.2 Copyright (C) Paul Johnston 1999 - 2009
|
||||||
|
* Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet
|
||||||
|
* Distributed under the BSD License
|
||||||
|
* See http://pajhome.org.uk/crypt/md5 for more info.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function md5(string) {
|
||||||
|
|
||||||
|
function RotateLeft(lValue, iShiftBits) {
|
||||||
|
return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits));
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddUnsigned(lX, lY) {
|
||||||
|
var lX4, lY4, lX8, lY8, lResult;
|
||||||
|
lX8 = (lX & 0x80000000);
|
||||||
|
lY8 = (lY & 0x80000000);
|
||||||
|
lX4 = (lX & 0x40000000);
|
||||||
|
lY4 = (lY & 0x40000000);
|
||||||
|
lResult = (lX & 0x3FFFFFFF) + (lY & 0x3FFFFFFF);
|
||||||
|
if (lX4 & lY4) {
|
||||||
|
return (lResult ^ 0x80000000 ^ lX8 ^ lY8);
|
||||||
|
}
|
||||||
|
if (lX4 | lY4) {
|
||||||
|
if (lResult & 0x40000000) {
|
||||||
|
return (lResult ^ 0xC0000000 ^ lX8 ^ lY8);
|
||||||
|
} else {
|
||||||
|
return (lResult ^ 0x40000000 ^ lX8 ^ lY8);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return (lResult ^ lX8 ^ lY8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function F(x, y, z) {
|
||||||
|
return (x & y) | ((~x) & z);
|
||||||
|
}
|
||||||
|
function G(x, y, z) {
|
||||||
|
return (x & z) | (y & (~z));
|
||||||
|
}
|
||||||
|
function H(x, y, z) {
|
||||||
|
return (x ^ y ^ z);
|
||||||
|
}
|
||||||
|
function I(x, y, z) {
|
||||||
|
return (y ^ (x | (~z)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function FF(a, b, c, d, x, s, ac) {
|
||||||
|
a = AddUnsigned(a, AddUnsigned(AddUnsigned(F(b, c, d), x), ac));
|
||||||
|
return AddUnsigned(RotateLeft(a, s), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GG(a, b, c, d, x, s, ac) {
|
||||||
|
a = AddUnsigned(a, AddUnsigned(AddUnsigned(G(b, c, d), x), ac));
|
||||||
|
return AddUnsigned(RotateLeft(a, s), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HH(a, b, c, d, x, s, ac) {
|
||||||
|
a = AddUnsigned(a, AddUnsigned(AddUnsigned(H(b, c, d), x), ac));
|
||||||
|
return AddUnsigned(RotateLeft(a, s), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function II(a, b, c, d, x, s, ac) {
|
||||||
|
a = AddUnsigned(a, AddUnsigned(AddUnsigned(I(b, c, d), x), ac));
|
||||||
|
return AddUnsigned(RotateLeft(a, s), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertToWordArray(string) {
|
||||||
|
var lWordCount;
|
||||||
|
var lMessageLength = string.length;
|
||||||
|
var lNumberOfWords_temp1 = lMessageLength + 8;
|
||||||
|
var lNumberOfWords_temp2 = (lNumberOfWords_temp1 - (lNumberOfWords_temp1 % 64)) / 64;
|
||||||
|
var lNumberOfWords = (lNumberOfWords_temp2 + 1) * 16;
|
||||||
|
var lWordArray = Array(lNumberOfWords - 1);
|
||||||
|
var lBytePosition = 0;
|
||||||
|
var lByteCount = 0;
|
||||||
|
while (lByteCount < lMessageLength) {
|
||||||
|
lWordCount = (lByteCount - (lByteCount % 4)) / 4;
|
||||||
|
lBytePosition = (lByteCount % 4) * 8;
|
||||||
|
lWordArray[lWordCount] = (lWordArray[lWordCount] | (string.charCodeAt(lByteCount) << lBytePosition));
|
||||||
|
lByteCount++;
|
||||||
|
}
|
||||||
|
lWordCount = (lByteCount - (lByteCount % 4)) / 4;
|
||||||
|
lBytePosition = (lByteCount % 4) * 8;
|
||||||
|
lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition);
|
||||||
|
lWordArray[lNumberOfWords - 2] = lMessageLength << 3;
|
||||||
|
lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29;
|
||||||
|
return lWordArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WordToHex(lValue) {
|
||||||
|
var WordToHexValue = "", WordToHexValue_temp = "", lByte, lCount;
|
||||||
|
for (lCount = 0; lCount <= 3; lCount++) {
|
||||||
|
lByte = (lValue >>> (lCount * 8)) & 255;
|
||||||
|
WordToHexValue_temp = "0" + lByte.toString(16);
|
||||||
|
WordToHexValue = WordToHexValue + WordToHexValue_temp.substr(WordToHexValue_temp.length - 2, 2);
|
||||||
|
}
|
||||||
|
return WordToHexValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Utf8Encode(string) {
|
||||||
|
string = string.replace(/\r\n/g, "\n");
|
||||||
|
var utftext = "";
|
||||||
|
|
||||||
|
for (var n = 0; n < string.length; n++) {
|
||||||
|
|
||||||
|
var c = string.charCodeAt(n);
|
||||||
|
|
||||||
|
if (c < 128) {
|
||||||
|
utftext += String.fromCharCode(c);
|
||||||
|
}
|
||||||
|
else if ((c > 127) && (c < 2048)) {
|
||||||
|
utftext += String.fromCharCode((c >> 6) | 192);
|
||||||
|
utftext += String.fromCharCode((c & 63) | 128);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
utftext += String.fromCharCode((c >> 12) | 224);
|
||||||
|
utftext += String.fromCharCode(((c >> 6) & 63) | 128);
|
||||||
|
utftext += String.fromCharCode((c & 63) | 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return utftext;
|
||||||
|
}
|
||||||
|
|
||||||
|
var x = Array();
|
||||||
|
var k, AA, BB, CC, DD, a, b, c, d;
|
||||||
|
var S11 = 7, S12 = 12, S13 = 17, S14 = 22;
|
||||||
|
var S21 = 5, S22 = 9, S23 = 14, S24 = 20;
|
||||||
|
var S31 = 4, S32 = 11, S33 = 16, S34 = 23;
|
||||||
|
var S41 = 6, S42 = 10, S43 = 15, S44 = 21;
|
||||||
|
|
||||||
|
string = Utf8Encode(string);
|
||||||
|
|
||||||
|
x = ConvertToWordArray(string);
|
||||||
|
|
||||||
|
a = 0x67452301; b = 0xEFCDAB89; c = 0x98BADCFE; d = 0x10325476;
|
||||||
|
|
||||||
|
for (k = 0; k < x.length; k += 16) {
|
||||||
|
AA = a; BB = b; CC = c; DD = d;
|
||||||
|
a = FF(a, b, c, d, x[k + 0], S11, 0xD76AA478);
|
||||||
|
d = FF(d, a, b, c, x[k + 1], S12, 0xE8C7B756);
|
||||||
|
c = FF(c, d, a, b, x[k + 2], S13, 0x242070DB);
|
||||||
|
b = FF(b, c, d, a, x[k + 3], S14, 0xC1BDCEEE);
|
||||||
|
a = FF(a, b, c, d, x[k + 4], S11, 0xF57C0FAF);
|
||||||
|
d = FF(d, a, b, c, x[k + 5], S12, 0x4787C62A);
|
||||||
|
c = FF(c, d, a, b, x[k + 6], S13, 0xA8304613);
|
||||||
|
b = FF(b, c, d, a, x[k + 7], S14, 0xFD469501);
|
||||||
|
a = FF(a, b, c, d, x[k + 8], S11, 0x698098D8);
|
||||||
|
d = FF(d, a, b, c, x[k + 9], S12, 0x8B44F7AF);
|
||||||
|
c = FF(c, d, a, b, x[k + 10], S13, 0xFFFF5BB1);
|
||||||
|
b = FF(b, c, d, a, x[k + 11], S14, 0x895CD7BE);
|
||||||
|
a = FF(a, b, c, d, x[k + 12], S11, 0x6B901122);
|
||||||
|
d = FF(d, a, b, c, x[k + 13], S12, 0xFD987193);
|
||||||
|
c = FF(c, d, a, b, x[k + 14], S13, 0xA679438E);
|
||||||
|
b = FF(b, c, d, a, x[k + 15], S14, 0x49B40821);
|
||||||
|
a = GG(a, b, c, d, x[k + 1], S21, 0xF61E2562);
|
||||||
|
d = GG(d, a, b, c, x[k + 6], S22, 0xC040B340);
|
||||||
|
c = GG(c, d, a, b, x[k + 11], S23, 0x265E5A51);
|
||||||
|
b = GG(b, c, d, a, x[k + 0], S24, 0xE9B6C7AA);
|
||||||
|
a = GG(a, b, c, d, x[k + 5], S21, 0xD62F105D);
|
||||||
|
d = GG(d, a, b, c, x[k + 10], S22, 0x2441453);
|
||||||
|
c = GG(c, d, a, b, x[k + 15], S23, 0xD8A1E681);
|
||||||
|
b = GG(b, c, d, a, x[k + 4], S24, 0xE7D3FBC8);
|
||||||
|
a = GG(a, b, c, d, x[k + 9], S21, 0x21E1CDE6);
|
||||||
|
d = GG(d, a, b, c, x[k + 14], S22, 0xC33707D6);
|
||||||
|
c = GG(c, d, a, b, x[k + 3], S23, 0xF4D50D87);
|
||||||
|
b = GG(b, c, d, a, x[k + 8], S24, 0x455A14ED);
|
||||||
|
a = GG(a, b, c, d, x[k + 13], S21, 0xA9E3E905);
|
||||||
|
d = GG(d, a, b, c, x[k + 2], S22, 0xFCEFA3F8);
|
||||||
|
c = GG(c, d, a, b, x[k + 7], S23, 0x676F02D9);
|
||||||
|
b = GG(b, c, d, a, x[k + 12], S24, 0x8D2A4C8A);
|
||||||
|
a = HH(a, b, c, d, x[k + 5], S31, 0xFFFA3942);
|
||||||
|
d = HH(d, a, b, c, x[k + 8], S32, 0x8771F681);
|
||||||
|
c = HH(c, d, a, b, x[k + 11], S33, 0x6D9D6122);
|
||||||
|
b = HH(b, c, d, a, x[k + 14], S34, 0xFDE5380C);
|
||||||
|
a = HH(a, b, c, d, x[k + 1], S31, 0xA4BEEA44);
|
||||||
|
d = HH(d, a, b, c, x[k + 4], S32, 0x4BDECFA9);
|
||||||
|
c = HH(c, d, a, b, x[k + 7], S33, 0xF6BB4B60);
|
||||||
|
b = HH(b, c, d, a, x[k + 10], S34, 0xBEBFBC70);
|
||||||
|
a = HH(a, b, c, d, x[k + 13], S31, 0x289B7EC6);
|
||||||
|
d = HH(d, a, b, c, x[k + 0], S32, 0xEAA127FA);
|
||||||
|
c = HH(c, d, a, b, x[k + 3], S33, 0xD4EF3085);
|
||||||
|
b = HH(b, c, d, a, x[k + 6], S34, 0x4881D05);
|
||||||
|
a = HH(a, b, c, d, x[k + 9], S31, 0xD9D4D039);
|
||||||
|
d = HH(d, a, b, c, x[k + 12], S32, 0xE6DB99E5);
|
||||||
|
c = HH(c, d, a, b, x[k + 15], S33, 0x1FA27CF8);
|
||||||
|
b = HH(b, c, d, a, x[k + 2], S34, 0xC4AC5665);
|
||||||
|
a = II(a, b, c, d, x[k + 0], S41, 0xF4292244);
|
||||||
|
d = II(d, a, b, c, x[k + 7], S42, 0x432AFF97);
|
||||||
|
c = II(c, d, a, b, x[k + 14], S43, 0xAB9423A7);
|
||||||
|
b = II(b, c, d, a, x[k + 5], S44, 0xFC93A039);
|
||||||
|
a = II(a, b, c, d, x[k + 12], S41, 0x655B59C3);
|
||||||
|
d = II(d, a, b, c, x[k + 3], S42, 0x8F0CCC92);
|
||||||
|
c = II(c, d, a, b, x[k + 10], S43, 0xFFEFF47D);
|
||||||
|
b = II(b, c, d, a, x[k + 1], S44, 0x85845DD1);
|
||||||
|
a = II(a, b, c, d, x[k + 8], S41, 0x6FA87E4F);
|
||||||
|
d = II(d, a, b, c, x[k + 15], S42, 0xFE2CE6E0);
|
||||||
|
c = II(c, d, a, b, x[k + 6], S43, 0xA3014314);
|
||||||
|
b = II(b, c, d, a, x[k + 13], S44, 0x4E0811A1);
|
||||||
|
a = II(a, b, c, d, x[k + 4], S41, 0xF7537E82);
|
||||||
|
d = II(d, a, b, c, x[k + 11], S42, 0xBD3AF235);
|
||||||
|
c = II(c, d, a, b, x[k + 2], S43, 0x2AD7D2BB);
|
||||||
|
b = II(b, c, d, a, x[k + 9], S44, 0xEB86D391);
|
||||||
|
a = AddUnsigned(a, AA);
|
||||||
|
b = AddUnsigned(b, BB);
|
||||||
|
c = AddUnsigned(c, CC);
|
||||||
|
d = AddUnsigned(d, DD);
|
||||||
|
}
|
||||||
|
|
||||||
|
var temp = WordToHex(a) + WordToHex(b) + WordToHex(c) + WordToHex(d);
|
||||||
|
|
||||||
|
return temp.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { md5 };
|
||||||
436
frontend/src/views/Home.vue
Normal file
436
frontend/src/views/Home.vue
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
<template>
|
||||||
|
<main id="app-main">
|
||||||
|
<section class="hero-section">
|
||||||
|
<h1>{{ $t('home.title') }}</h1>
|
||||||
|
<p>{{ $t('home.subtitle') }}<code>@{{ domain }}</code>{{ $t('home.subtitleAfter') }}</p>
|
||||||
|
<form @submit.prevent="fetchMessages" class="input-group">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
v-model="recipient"
|
||||||
|
class="email-input"
|
||||||
|
:placeholder="$t('home.placeholder')"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button @click="copyEmail" type="button" class="btn-copy" :title="$t('home.copyTitle')">
|
||||||
|
<el-icon v-if="copyStatus === 'copied'"><Check /></el-icon>
|
||||||
|
<el-icon v-else><CopyDocument /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button @click="generateRandomEmail" type="button" class="btn btn-secondary">{{ $t('home.random') }}</button>
|
||||||
|
<button type="submit" class="btn btn-primary">{{ $t('home.checkInbox') }}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="inbox-section">
|
||||||
|
<div class="inbox-container">
|
||||||
|
<div class="message-list">
|
||||||
|
<div class="message-list-header">
|
||||||
|
<h2>{{ $t('home.inbox') }}</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button @click="clearInbox" :title="$t('home.clearInbox')" :disabled="messages.length === 0">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
</button>
|
||||||
|
<button @click="fetchMessages" :title="$t('home.refresh')">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="newMailNotification" class="new-mail-notification">
|
||||||
|
{{ $t('home.newMailNotification') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="loading-state">{{ $t('home.loading') }}</div>
|
||||||
|
<div v-else-if="messages.length === 0" class="empty-state">{{ $t('home.noMail') }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
v-for="message in messages"
|
||||||
|
:key="message.id"
|
||||||
|
class="message-item"
|
||||||
|
:class="{ selected: selectedMessage && selectedMessage.id === message.id }"
|
||||||
|
@click="selectMessage(message)"
|
||||||
|
>
|
||||||
|
<div class="from">{{ message.sender }}</div>
|
||||||
|
<div class="subject">{{ message.subject }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="message-detail">
|
||||||
|
<div v-if="!selectedMessage" class="empty-state">
|
||||||
|
<p>{{ $t('home.selectMail') }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="message-content-header">
|
||||||
|
<div class="header-text">
|
||||||
|
<h3>{{ selectedMessage.subject }}</h3>
|
||||||
|
<p class="from-line"><strong>{{ $t('home.from') }}:</strong> {{ selectedMessage.sender }}</p>
|
||||||
|
<p><strong>{{ $t('home.to') }}:</strong> {{ selectedMessage.recipient }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button @click="deleteMessage(selectedMessage.id)" :title="$t('home.delete')">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="message-body" v-html="renderedBody">
|
||||||
|
</div>
|
||||||
|
<div v-if="attachmentsLoading" class="loading-state">{{ $t('home.loading') }}</div>
|
||||||
|
<div v-if="attachments.length > 0" class="attachments-section">
|
||||||
|
<h4>{{ $t('home.attachments') }}</h4>
|
||||||
|
<ul>
|
||||||
|
<li v-for="attachment in attachments" :key="attachment.id">
|
||||||
|
<a :href="`/api/attachments/${attachment.id}`" :download="attachment.filename">{{ attachment.filename }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onUnmounted, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { Delete, Refresh, CopyDocument, Check } from '@element-plus/icons-vue';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const recipient = ref('');
|
||||||
|
const messages = ref([]);
|
||||||
|
const selectedMessage = ref(null);
|
||||||
|
const domain = import.meta.env.VITE_APP_DOMAIN;
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
selectedMessage.value = {
|
||||||
|
id: 'sample-1',
|
||||||
|
sender: 'demo@example.com',
|
||||||
|
recipient: `you@${domain}`,
|
||||||
|
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> 这是一条重要的提醒:请提前准备好您的问题。`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const loading = ref(false);
|
||||||
|
const copyStatus = ref('idle'); // 'idle' | 'copied'
|
||||||
|
const attachments = ref([]);
|
||||||
|
const attachmentsLoading = ref(false);
|
||||||
|
const newMailNotification = ref(false);
|
||||||
|
|
||||||
|
|
||||||
|
const renderedBody = computed(() => {
|
||||||
|
if (selectedMessage.value && selectedMessage.value.body) {
|
||||||
|
return marked(selectedMessage.value.body);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
const setupWebSocket = () => {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recipient.value) return;
|
||||||
|
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${wsProtocol}//${window.location.host}/ws?recipient=${encodeURIComponent(recipient.value)}`;
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('WebSocket connection established');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const newEmail = JSON.parse(event.data);
|
||||||
|
messages.value.unshift(newEmail);
|
||||||
|
ElMessage.success(t('home.newMailNotification'));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket connection closed');
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchMessages = async () => {
|
||||||
|
if (!recipient.value) {
|
||||||
|
ElMessage.warning(t('home.alerts.enterEmail'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ElMessage.info(t('home.alerts.checkInboxStart'));
|
||||||
|
loading.value = true;
|
||||||
|
selectedMessage.value = null;
|
||||||
|
attachments.value = [];
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/messages?recipient=${recipient.value}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Network response was not ok');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
messages.value = data;
|
||||||
|
if (messages.value.length > 0) {
|
||||||
|
selectMessage(messages.value[0]);
|
||||||
|
}
|
||||||
|
setupWebSocket(); // Setup WebSocket after fetching messages
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch messages:', error);
|
||||||
|
ElMessage.error(t('home.alerts.fetchFailed'));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAttachments = async (messageId) => {
|
||||||
|
attachmentsLoading.value = true;
|
||||||
|
attachments.value = [];
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/messages/${messageId}/attachments`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Network response was not ok');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
attachments.value = data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch attachments:', error);
|
||||||
|
} finally {
|
||||||
|
attachmentsLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectMessage = (message) => {
|
||||||
|
selectedMessage.value = message;
|
||||||
|
if (message) {
|
||||||
|
fetchAttachments(message.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMessage = async (messageId) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
t('home.alerts.confirmDelete'),
|
||||||
|
t('home.delete'),
|
||||||
|
{
|
||||||
|
confirmButtonText: 'OK',
|
||||||
|
cancelButtonText: 'Cancel',
|
||||||
|
type: 'warning',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/messages/${messageId}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Network response was not ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = messages.value.findIndex(m => m.id === messageId);
|
||||||
|
if (index !== -1) {
|
||||||
|
messages.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedMessage.value && selectedMessage.value.id === messageId) {
|
||||||
|
const newIndex = Math.max(0, index - 1);
|
||||||
|
selectedMessage.value = messages.value.length > 0 ? messages.value[newIndex] : null;
|
||||||
|
if (selectedMessage.value) {
|
||||||
|
selectMessage(selectedMessage.value);
|
||||||
|
} else {
|
||||||
|
attachments.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElMessage.success(t('home.alerts.deleteSuccess'));
|
||||||
|
} catch (action) {
|
||||||
|
if (action === 'cancel') {
|
||||||
|
ElMessage.info(t('home.alerts.actionCancelled'));
|
||||||
|
} else {
|
||||||
|
console.error('Failed to delete message:', action);
|
||||||
|
ElMessage.error(t('home.alerts.deleteFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearInbox = async () => {
|
||||||
|
if (!recipient.value || messages.value.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
t('home.alerts.confirmClearAll'),
|
||||||
|
t('home.clearInbox'),
|
||||||
|
{
|
||||||
|
confirmButtonText: 'OK',
|
||||||
|
cancelButtonText: 'Cancel',
|
||||||
|
type: 'warning',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/messages?recipient=${recipient.value}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Network response was not ok');
|
||||||
|
}
|
||||||
|
messages.value = [];
|
||||||
|
selectedMessage.value = null;
|
||||||
|
attachments.value = [];
|
||||||
|
ElMessage.success(t('home.alerts.clearSuccess'));
|
||||||
|
} catch (action) {
|
||||||
|
if (action === 'cancel') {
|
||||||
|
ElMessage.info(t('home.alerts.actionCancelled'));
|
||||||
|
} else {
|
||||||
|
console.error('Failed to clear inbox:', action);
|
||||||
|
ElMessage.error(t('home.alerts.clearFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateRandomEmail = () => {
|
||||||
|
const names = [
|
||||||
|
'alex', 'casey', 'morgan', 'jordan', 'taylor', 'jamie', 'ryan', 'drew', 'jesse', 'pat',
|
||||||
|
'chris', 'dylan', 'aaron', 'blake', 'cameron', 'devon', 'elliot', 'finn', 'gray', 'harper',
|
||||||
|
'kai', 'logan', 'max', 'noah', 'owen', 'quinn', 'riley', 'rowan', 'sage', 'skyler'
|
||||||
|
];
|
||||||
|
const places = [
|
||||||
|
'tokyo', 'paris', 'london', 'cairo', 'sydney', 'rio', 'moscow', 'rome', 'nile', 'everest',
|
||||||
|
'sahara', 'amazon', 'gobi', 'andes', 'pacific', 'kyoto', 'berlin', 'dubai', 'seoul', 'milan',
|
||||||
|
'vienna', 'prague', 'athens', 'lisbon', 'oslo', 'helsinki', 'zürich', 'geneva', 'brussels', 'amsterdam'
|
||||||
|
];
|
||||||
|
const concepts = [
|
||||||
|
'apollo', 'artemis', 'athena', 'zeus', 'thor', 'loki', 'odin', 'freya', 'phoenix', 'dragon',
|
||||||
|
'griffin', 'sphinx', 'pyramid', 'colossus', 'acropolis', 'obelisk', 'pagoda', 'castle', 'cyberspace', 'matrix',
|
||||||
|
'protocol', 'algorithm', 'pixel', 'vector', 'photon', 'quark', 'nova', 'pulsar', 'saga', 'voyage',
|
||||||
|
'enigma', 'oracle', 'cipher', 'vortex', 'helix', 'axiom', 'zenith', 'epoch', 'nexus', 'trinity'
|
||||||
|
];
|
||||||
|
|
||||||
|
const allLists = [names, places, concepts];
|
||||||
|
const getRandomItem = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
|
||||||
|
const listA = getRandomItem(allLists);
|
||||||
|
const listB = getRandomItem(allLists);
|
||||||
|
|
||||||
|
const word1 = getRandomItem(listA);
|
||||||
|
const word2 = getRandomItem(listB);
|
||||||
|
|
||||||
|
const number = Math.floor(Math.random() * 9000) + 1000;
|
||||||
|
|
||||||
|
const separators = ['.', '-', '_', ''];
|
||||||
|
const separator = getRandomItem(separators);
|
||||||
|
|
||||||
|
let prefix;
|
||||||
|
if (word1 === word2) {
|
||||||
|
prefix = `${word1}${number}`;
|
||||||
|
} else {
|
||||||
|
prefix = `${word1}${separator}${word2}${number}`;
|
||||||
|
}
|
||||||
|
ElMessage.success(t('home.alerts.generateRandomSuccess'));
|
||||||
|
recipient.value = `${prefix}@${domain}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyEmail = async () => {
|
||||||
|
if (!recipient.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(recipient.value);
|
||||||
|
} else {
|
||||||
|
// fallback 方案
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = recipient.value;
|
||||||
|
textArea.style.position = 'fixed'; // 防止滚动时跳动
|
||||||
|
textArea.style.left = '-9999px';
|
||||||
|
textArea.style.top = '-9999px';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
const success = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
if (!success) throw new Error('Fallback copy failed');
|
||||||
|
}
|
||||||
|
copyStatus.value = 'copied';
|
||||||
|
ElMessage.success(t('home.alerts.copySuccess'));
|
||||||
|
setTimeout(() => copyStatus.value = 'idle', 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Copy failed:', err);
|
||||||
|
ElMessage.error(t('home.alerts.copyFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-body {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: normal; /* 覆盖 pre-wrap 样式 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
为动态渲染的 HTML 内容设置样式
|
||||||
|
使用 :deep() 来穿透 Vue 的作用域限制
|
||||||
|
*/
|
||||||
|
.message-body :deep(> :first-child) {
|
||||||
|
margin-top: 0; /* 移除第一个元素的上边距 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body :deep(> :last-child) {
|
||||||
|
margin-bottom: 0; /* 移除最后一个元素的下边距 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body :deep(h1),
|
||||||
|
.message-body :deep(h2),
|
||||||
|
.message-body :deep(h3),
|
||||||
|
.message-body :deep(h4),
|
||||||
|
.message-body :deep(h5),
|
||||||
|
.message-body :deep(h6) {
|
||||||
|
margin-top: 1.2em; /* 为标题设置合适的上边距 */
|
||||||
|
margin-bottom: 0.6em; /* 减小标题和下方内容的间距 */
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body :deep(p) {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.8em; /* 为段落设置合适的下边距 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body :deep(ul),
|
||||||
|
.message-body :deep(ol) {
|
||||||
|
padding-left: 1.5em; /* 调整列表的缩进 */
|
||||||
|
margin-bottom: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body :deep(blockquote) {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 3px solid #ccc;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body :deep(hr) {
|
||||||
|
margin: 1.5em 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
frontend/src/views/Settings.vue
Normal file
107
frontend/src/views/Settings.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<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>
|
||||||
|
<div class="settings-page">
|
||||||
|
<div class="settings-container">
|
||||||
|
<h2>{{ $t('howItWorks.title') }}</h2>
|
||||||
|
<ol>
|
||||||
|
<i18n-t keypath="howItWorks.step1" tag="li">
|
||||||
|
<template #domain>
|
||||||
|
<code>@{{ domain }}</code>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
<li>{{ $t('howItWorks.step2') }}</li>
|
||||||
|
<li>{{ $t('howItWorks.step3') }}</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="language-switcher">
|
||||||
|
<h3>{{ $t('language') }}</h3>
|
||||||
|
<button @click="setLanguage('zh')" :class="{ active: currentLocale === 'zh' }">中文</button>
|
||||||
|
<button @click="setLanguage('en')" :class="{ active: currentLocale === 'en' }">English</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'Settings',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-page {
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
background-color: var(--light-grey);
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: var(--primary-purple);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--dark-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: var(--light-purple);
|
||||||
|
color: var(--text-light);
|
||||||
|
padding: 0.3em 0.6em;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-switcher {
|
||||||
|
margin-top: 2rem;
|
||||||
|
border-top: 1px solid var(--medium-grey);
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-switcher h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: var(--dark-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-switcher button {
|
||||||
|
margin-right: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--medium-grey);
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-switcher button.active {
|
||||||
|
background-color: var(--primary-purple);
|
||||||
|
color: var(--text-light);
|
||||||
|
border-color: var(--primary-purple);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
46
frontend/vite.config.js
Normal file
46
frontend/vite.config.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
manifest: {
|
||||||
|
name: 'Email Unlimit',
|
||||||
|
short_name: 'EmailUnlimit',
|
||||||
|
description: 'Your Exclusive, Unlimited, and Private Temporary Email',
|
||||||
|
theme_color: '#000000',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'icons/icon.svg',
|
||||||
|
sizes: 'any',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: process.env.NODE_ENV === 'production' ? {} : {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5182',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:5182',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
'process.env': process.env
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
const { defineConfig } = require('@vue/cli-service')
|
|
||||||
|
|
||||||
module.exports = defineConfig({
|
|
||||||
transpileDependencies: true
|
|
||||||
// devServer 代理配置已移除,所有代理均由外部 Nginx 处理。
|
|
||||||
})
|
|
||||||
26
mailu.env
26
mailu.env
@@ -1,26 +0,0 @@
|
|||||||
SECRET_KEY=d4f7a1b3c2e987f05a6d3b4c8e1f2097
|
|
||||||
DOMAIN=shenjianl.cn
|
|
||||||
HOSTNAMES=mail.shenjianl.cn
|
|
||||||
POSTMASTER=admin
|
|
||||||
|
|
||||||
DB_FLAVOR=mysql
|
|
||||||
DB_HOST=43.143.145.172
|
|
||||||
DB_USER=root
|
|
||||||
DB_PASSWORD=kyff145972
|
|
||||||
DB_NAME=maildb
|
|
||||||
|
|
||||||
WEB_ADMIN=/mailu
|
|
||||||
WEB_WEBMAIL=/mailu/webmail
|
|
||||||
|
|
||||||
ENABLE_UNPRIVILEGED_USER=false
|
|
||||||
PASSWORD_SCHEME=BLF-CRYPT
|
|
||||||
ENABLE_ADMIN=true
|
|
||||||
ENABLE_WEBMAIL=true
|
|
||||||
ENABLE_WEBDAV=false
|
|
||||||
ENABLE_ANTIVIRUS=false
|
|
||||||
ENABLE_ANTISPAM=true
|
|
||||||
DEFAULT_QUOTA=2G
|
|
||||||
ADMIN_PASSWORD=admin
|
|
||||||
|
|
||||||
TLS_FLAVOR=notls
|
|
||||||
MESSAGE_SIZE_LIMIT=20000000
|
|
||||||
69
nginx.full.conf
Normal file
69
nginx.full.conf
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
worker_processes 1;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
charset utf-8;
|
||||||
|
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 / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 反向代理 /api 到后端服务
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://email-backend: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket 支持
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://email-backend:5182;
|
||||||
|
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: 204 KiB |
Reference in New Issue
Block a user