Compare commits
32 Commits
d85b531e24
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f785e7defc | |||
| 742aa4d2fc | |||
| 9ab3621658 | |||
| ae870fb601 | |||
| 77030cc8fc | |||
| bb25928a8e | |||
| 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 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -2,4 +2,8 @@
|
|||||||
/frontend/node_modules
|
/frontend/node_modules
|
||||||
/backend/package-lock.json
|
/backend/package-lock.json
|
||||||
/frontend/package-lock.json
|
/frontend/package-lock.json
|
||||||
/frontend/dist
|
/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.
|
||||||
156
README.md
156
README.md
@@ -1,123 +1,95 @@
|
|||||||
# 轻量级临时邮件项目 (Email Unlimit)
|
# Email Unlimit - 轻量级、可自托管的临时邮件解决方案
|
||||||
|
|
||||||
本项目是一个轻量级的、可自托管的临时邮件解决方案。它允许您使用自己的域名接收邮件,并通过一个简洁的网页界面来查看这些邮件。
|
本项目是一个轻量级的、可自托管的临时邮件解决方案。它允许您使用自己的域名接收邮件,并通过一个简洁的网页界面来查看这些邮件。
|
||||||
|
|
||||||
与 `Mailu` 等复杂的邮件套件不同,本项目采用了一个极简的 Node.js 服务来直接接收和处理邮件,部署和维护都非常简单。
|
与 `Mailu` 等复杂的邮件套件不同,本项目采用了一个极简的 Node.js 服务来直接接收和处理邮件,部署和维护都非常简单。
|
||||||
|
|
||||||
|
**在线体验:** <a href="https://mail.shenjianl.cn" target="_blank">https://mail.shenjianl.cn</a>
|
||||||
|
|
||||||
|
**网站截图**:
|
||||||
|

|
||||||
|
|
||||||
## 技术架构
|
## 技术架构
|
||||||
|
|
||||||
* **前端 (Frontend)**: 使用 Vue.js 构建的单页面应用,负责展示收到的邮件列表。
|
* **前端 (Frontend)**: 使用 Vue.js 构建的单页面应用,负责展示收到的邮件列表。
|
||||||
* **后端 (Backend)**:
|
* **后端 (Backend)**:
|
||||||
* 使用 Node.js 和 Express 搭建的 API 服务器。
|
* 使用 Node.js 和 Express 搭建的 API 服务器。
|
||||||
* 内置一个轻量级的 SMTP 服务器 (`smtp-server`),用于直接接收邮件,无需外部邮件服务。
|
* 内置一个轻量级的 SMTP 服务器 (`smtp-server`),用于直接接收邮件,无需外部邮件服务。
|
||||||
* 负责将收到的邮件解析并存入数据库。
|
* 通过 WebSocket 实现新邮件的实时推送。
|
||||||
* **数据库 (Database)**: 需要一个外部的 MySQL 数据库来存储邮件信息。
|
* **数据库 (Database)**: 使用 MySQL 存储邮件信息,确保数据的安全性和持久性。
|
||||||
* **部署 (Deployment)**: 后端服务通过 Docker Compose 进行容器化部署,前端静态文件由宿主机的 Nginx 提供服务。
|
* **部署 (Deployment)**: 使用 Docker Compose 统一管理后端服务、数据库和 Nginx,实现一键部署。
|
||||||
|
|
||||||
## 部署要求
|
## 前置条件
|
||||||
|
|
||||||
在开始之前,<EFBFBD><EFBFBD>确保您已准备好以下环境:
|
在开始之前,请确保您已满足以下条件:
|
||||||
|
|
||||||
1. 一台拥有公网 IP 的 Linux 服务器。
|
### 1. 硬件与域名
|
||||||
2. 一个您自己的域名。
|
- 一台拥有公网 IP 的云服务器。
|
||||||
3. 服务器上已安装 `Docker` 和 `Docker Compose`。
|
- 一个您自己的域名(建议已完成备案)。
|
||||||
4. 服务器上已安装 `Nginx`。
|
|
||||||
5. 一个可用的外部 MySQL 数据库,并已创建好数据库。
|
|
||||||
6. 本地开发环境已安装 `Node.js` 和 `npm` (用于构建前端)。
|
|
||||||
|
|
||||||
## 部署步骤
|
### 2. 软件环境
|
||||||
|
- 服务器上已安装 [Docker 和 Docker Compose](https://docs.docker.com/get-docker/)。
|
||||||
|
- 本地开发环境已安装 [Node.js](https://nodejs.org/) (仅用于构建前端)。
|
||||||
|
|
||||||
### 步骤 1: 配置域名 DNS
|
### 3. 域名 DNS 配置
|
||||||
|
要让邮件能正确发送到您的服务器,您必须配置域名的 `A` 记录和 `MX` 记录。假设您的域名是 `example.com`,您希望用于收信的子域名是 `mail.example.com`。
|
||||||
|
|
||||||
要让邮件能正确发送到您的服务器,您必须配置域名的 `MX` 记录。
|
| 类型 | 主机记录/名称 | 记录值/指向 | 备注 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
1. 登录您的域名注册商(如 GoDaddy, Cloudflare 等)。
|
| A | mail | `您的服务器公网 IP` | 将 `mail` 子域名指向您的服务器 |
|
||||||
2. 找到 DNS 解析设置。
|
| MX | @ | `mail.example.com` | 优先级设置为 10 |
|
||||||
3. 添加一条 `MX` 记录:
|
|
||||||
* **类型 (Type)**: `MX`
|
|
||||||
* **名称 (Name/Host)**: `@` (代表您的根域名)
|
|
||||||
* **值 (Value/Points to)**: `您的服务器公网 IP 地址`
|
|
||||||
* **优先级 (Priority)**: `10`
|
|
||||||
|
|
||||||
> **注意**: DNS 记录生效可能需要几分钟到几小时。
|
> **注意**: DNS 记录生效可能需要几分钟到几小时。
|
||||||
|
|
||||||
### 步骤 2: 部署后端服务
|
### 4. 申请 SSL/TLS 证书
|
||||||
|
为了启用 HTTPS,您需要为您的域名 `mail.example.com` 申请 SSL 证书。您可以从 Let's Encrypt、阿里云、腾讯云等证书颁发机构免费获取。
|
||||||
|
|
||||||
1. 将本项目克隆或上传到您的服务器。
|
## 部署步骤
|
||||||
2. 进入项目根目录,编辑 `docker-compose.yml` 文件。
|
|
||||||
3. **填写您的外部数据库连接信息**:
|
本部署方案使用 Docker Compose 统一管理所有服务(Nginx, Backend, MySQL),实现一键启动,无需在宿主机上安装 Nginx 或其他依赖。
|
||||||
```yaml
|
|
||||||
services:
|
### 步骤 1: 获取并准备项目
|
||||||
backend:
|
将项目代码下载并解压到您的服务器,例如 `/data/email-unlimit` 目录。
|
||||||
# ...
|
|
||||||
environment:
|
### 步骤 2: 上传 SSL 证书
|
||||||
- DB_HOST=your_external_db_host # 替换为您的外部数据库主机名或IP
|
将您申请到的 SSL 证书文件(通常是 `.pem`/.`crt` 和 `.key` 文件)上传到项目的 `certs` 目录下 (`/data/email-unlimit/certs`)。
|
||||||
- DB_USER=your_external_db_user # 替换为您的数据库用户名
|
|
||||||
- DB_PASSWORD=your_external_db_password # 替换为您的数据库密码
|
### 步骤 3: 构建前端静态文件 (本地环境)
|
||||||
- DB_NAME=your_external_db_name # 替换为您的数据库名称
|
1. 在您的**本地开发机**上,进入 `frontend` 目录,修改 `.env` 环境变量文件:
|
||||||
|
```env
|
||||||
|
VITE_APP_DOMAIN=example.com # 改为你的主域名
|
||||||
```
|
```
|
||||||
4. 在 `backend` 目录下有一个 `init.sql` 文件,请手动将其中的 SQL 命令在您的外部数据库中执行,以创建所需的表。
|
2. 安装依赖并构建:
|
||||||
5. 在项目根目录,使用 Docker Compose 启动后端服务:
|
|
||||||
```bash
|
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
|
||||||
此命令会构建并以后台模式启动后端容器。服务将监听服务器的 `5182` (API) 和 `25` (SMTP) 端口。
|
|
||||||
|
|
||||||
### 步骤 3: 构建和部署前端
|
|
||||||
|
|
||||||
1. **在您的本地开发机上**,进入 `frontend` 目录。
|
|
||||||
2. 安装依赖并构建静态文件:
|
|
||||||
```bash
|
```bash
|
||||||
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
这将在 `frontend/dist` 目录下生成所有用于部署的静态文件。
|
3. 将构建生成的 `dist` 目录(位于 `frontend/dist`)**完整上传**到服务器的 `/data/email-unlimit/frontend` 目录下。
|
||||||
3. 将 `frontend/dist` 目录下的 **所有文件** 上传到您服务器的指定位置,例如 `/var/www/email-unlimit`。
|
|
||||||
|
|
||||||
### 步骤 4: 配置宿主机 Nginx
|
### 步骤 4: 修改 Nginx 配置 (服务器)
|
||||||
|
编辑项目根目录下的 `nginx.full.conf` 文件,仅需修改以下三项:
|
||||||
|
```nginx
|
||||||
|
server_name mail.example.com; # 改为您自己的域名
|
||||||
|
ssl_certificate /etc/nginx/certs/your_certificate.pem; # 改为您自己的证书文件名
|
||||||
|
ssl_certificate_key /etc/nginx/certs/your_certificate.key; # 改为您自己的密钥文件名
|
||||||
|
```
|
||||||
|
> **注意**: 配置文件中的证书路径 `/etc/nginx/certs/` 是容器内的路径,它会映射到宿主机的 `/data/email-unlimit/certs` 目录,请确保文件名正确。
|
||||||
|
|
||||||
1. 在服务器上,为您的应用创建一个 Nginx 配置文件,例如 `/etc/nginx/sites-available/email.conf`。
|
### 步骤 5: 启动服务 (服务器)
|
||||||
2. 将以下配置写入该文件。请务必将 `your_domain.com` 和 `root` 路径修改为您自己的配置。
|
在服务器的项目根目录 (`/data/email-unlimit`) 下执行以下命令:
|
||||||
|
```bash
|
||||||
```nginx
|
# -f 指定使用 docker-compose.full.yml 配置文件,-d 表示后台运行
|
||||||
server {
|
docker compose -f docker-compose.full.yml up -d
|
||||||
listen 443 ssl;
|
```
|
||||||
server_name mail.shenjianl.cn; # 替换为您的域名
|
服务启动后,您可以通过 `docker compose -f docker-compose.full.yml logs -f` 查看实时日志。
|
||||||
ssl_certificate /usr/local/nginx/conf/ssl_certificate/mail/mail.shenjianl.cn_bundle.pem;
|
|
||||||
ssl_certificate_key /usr/local/nginx/conf/ssl_certificate/mail/mail.shenjianl.cn.key;
|
|
||||||
# 前端静态文件路径
|
|
||||||
root /data/email-unlimit/frontend/dist;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# 处理 Vue Router 的 history 模式
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 将 /api 请求反向代理到后端 Docker 容器
|
|
||||||
location /api {
|
|
||||||
proxy_pass http://localhost:5182;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. 启用该配置并重启 Nginx:
|
|
||||||
```bash
|
|
||||||
# 创建软链接
|
|
||||||
sudo ln -s /etc/nginx/sites-available/email.conf /etc/nginx/sites-enabled/
|
|
||||||
|
|
||||||
# 测试配置语法
|
|
||||||
sudo nginx -t
|
|
||||||
|
|
||||||
# 重启 Nginx
|
|
||||||
sudo systemctl restart nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
## 如何使用
|
## 如何使用
|
||||||
|
|
||||||
1. **访问您的网站**: 在浏览器中打开 `http://your_domain.com`。
|
1. **访问您的网站**: 在浏览器中打开 `https://mail.example.com` (替换为您的域名)。
|
||||||
2. **发送测试邮件**: 使用任何邮箱客户端,向 `anything@your_domain.com` (例如 `test@your_domain.com`) 发送一封邮件。
|
2. **发送测试邮件**: 使用任何邮箱客户端,向 `anything@example.com` (例如 `test@example.com`) 发送一封邮件。
|
||||||
3. **查看邮件**: 在网站上输入您刚刚使用的收件人地址 (`anything@your_domain.com`),点击查询,即可看到收到的邮件。
|
3. **查看邮件**: 在网站首页的输入框中输入您刚刚使用的收件人地址 (`test@example.com`),点击查询,即可看到收到的邮件。
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
本项目采用 MIT 许可证。详情请参阅 [LICENSE](LICENSE) 文件。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Set timezone to Asia/Shanghai FIRST
|
||||||
|
# This ensures that any native modules compiled during npm install are linked correctly.
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
@@ -17,4 +22,4 @@ EXPOSE 25
|
|||||||
# ENV DB_PASSWORD=...
|
# ENV DB_PASSWORD=...
|
||||||
# ENV DB_NAME=...
|
# ENV DB_NAME=...
|
||||||
|
|
||||||
CMD [ "node", "app.js" ]
|
CMD [ "node", "app.js" ]
|
||||||
145
backend/app.js
145
backend/app.js
@@ -1,13 +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 { SMTPServer } = require('smtp-server');
|
||||||
const { saveEmail } = require('./saveEmail');
|
const { saveEmail } = require('./saveEmail');
|
||||||
|
const emitter = require('./eventEmitter');
|
||||||
|
const logger = require('./logger');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const apiPort = 5182;
|
const apiPort = 5182;
|
||||||
const smtpPort = 25;
|
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());
|
||||||
|
|
||||||
@@ -15,24 +23,141 @@ 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, recipient, subject, body, received_at FROM emails WHERE recipient = ? ORDER BY received_at DESC',
|
'SELECT id, sender, recipient, subject, body, CAST(received_at AS CHAR) as received_at FROM emails WHERE recipient LIKE ? ORDER BY received_at DESC',
|
||||||
[recipient]
|
[`%${recipient}%`]
|
||||||
);
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} 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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API to get attachments for a message
|
||||||
|
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
|
// Start API server
|
||||||
app.listen(apiPort, () => {
|
server.listen(apiPort, () => {
|
||||||
console.log(`Backend API server listening at http://localhost:${apiPort}`);
|
logger.info(`Backend API and WebSocket server listening at http://localhost:${apiPort}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configure and start SMTP server
|
// Configure and start SMTP server
|
||||||
@@ -40,23 +165,23 @@ const smtpServer = new SMTPServer({
|
|||||||
authOptional: true,
|
authOptional: true,
|
||||||
disabledCommands: ['AUTH'],
|
disabledCommands: ['AUTH'],
|
||||||
onData(stream, session, callback) {
|
onData(stream, session, callback) {
|
||||||
console.log('Receiving email...');
|
logger.info('Receiving email...', { session });
|
||||||
saveEmail(stream)
|
saveEmail(stream)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Email processed and saved successfully.');
|
logger.info('Email processed and saved successfully.');
|
||||||
callback(); // Accept the message
|
callback(); // Accept the message
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Error processing email:', err);
|
logger.error('Error processing email:', err);
|
||||||
callback(new Error('Failed to process email.'));
|
callback(new Error('Failed to process email.'));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
smtpServer.on('error', err => {
|
smtpServer.on('error', err => {
|
||||||
console.error('SMTP Server Error:', err.message);
|
logger.error('SMTP Server Error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
smtpServer.listen(smtpPort, () => {
|
smtpServer.listen(smtpPort, () => {
|
||||||
console.log(`SMTP server listening on port ${smtpPort}`);
|
logger.info(`SMTP server listening on port ${smtpPort}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,18 @@ 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) {
|
||||||
|
logger.info('Executing SQL', { sql, params });
|
||||||
|
return originalExecute.call(this, sql, params);
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalQuery = promisePool.query;
|
||||||
|
promisePool.query = function(sql, params) {
|
||||||
|
logger.info('Executing SQL', { sql, params });
|
||||||
|
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;
|
||||||
@@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS emails (
|
|||||||
sender VARCHAR(255) NOT NULL,
|
sender VARCHAR(255) NOT NULL,
|
||||||
subject VARCHAR(512),
|
subject VARCHAR(512),
|
||||||
body TEXT,
|
body TEXT,
|
||||||
received_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
received_at DATETIME,
|
||||||
raw MEDIUMBLOB
|
raw MEDIUMBLOB
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -15,4 +15,4 @@ CREATE TABLE IF NOT EXISTS email_attachments (
|
|||||||
content_type VARCHAR(128),
|
content_type VARCHAR(128),
|
||||||
content LONGBLOB,
|
content LONGBLOB,
|
||||||
FOREIGN KEY (email_id) REFERENCES emails(id) ON DELETE CASCADE
|
FOREIGN KEY (email_id) REFERENCES emails(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
42
backend/logger.js
Normal file
42
backend/logger.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const winston = require('winston');
|
||||||
|
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: 'info',
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp({
|
||||||
|
format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
tz: 'Asia/Shanghai'
|
||||||
|
}),
|
||||||
|
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.timestamp({
|
||||||
|
format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
tz: 'Asia/Shanghai'
|
||||||
|
}),
|
||||||
|
winston.format.printf(({ level, message, timestamp, stack }) => {
|
||||||
|
if (stack) {
|
||||||
|
// 打印错误堆栈
|
||||||
|
return `${timestamp} ${level}: ${message}\n${stack}`;
|
||||||
|
}
|
||||||
|
return `${timestamp} ${level}: ${message}`;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
"smtp-server": "^3.13.4"
|
"smtp-server": "^3.13.4",
|
||||||
|
"ws": "^8.17.1",
|
||||||
|
"winston": "^3.13.0",
|
||||||
|
"morgan": "^1.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const { simpleParser } = require('mailparser');
|
const { simpleParser } = require('mailparser');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
|
const emitter = require('./eventEmitter');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
// Helper function to convert stream to buffer
|
// Helper function to convert stream to buffer
|
||||||
function streamToBuffer(stream) {
|
function streamToBuffer(stream) {
|
||||||
@@ -18,36 +20,51 @@ async function saveEmail(stream) {
|
|||||||
|
|
||||||
// Now, parse the buffered email content
|
// Now, parse the buffered email content
|
||||||
const parsed = await simpleParser(emailBuffer);
|
const parsed = await simpleParser(emailBuffer);
|
||||||
const rawEmail = emailBuffer.toString();
|
// const rawEmail = emailBuffer.toString(); // We are not saving the raw email content for now.
|
||||||
|
|
||||||
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 subject = parsed.subject || 'No Subject';
|
||||||
const body = parsed.text || (parsed.html || '');
|
const body = parsed.text || (parsed.html || '');
|
||||||
|
|
||||||
const [result] = await db.execute(
|
// Manually create a timestamp for 'Asia/Shanghai' timezone
|
||||||
'INSERT INTO emails (recipient, sender, subject, body, raw) VALUES (?, ?, ?, ?, ?)',
|
const received_at = new Date().toLocaleString('sv-SE', {
|
||||||
[recipient, sender, subject, body, rawEmail]
|
timeZone: 'Asia/Shanghai'
|
||||||
);
|
});
|
||||||
|
|
||||||
console.log(`Email from <${sender}> to <${recipient}> saved with ID: ${result.insertId}`);
|
const [result] = await db.execute(
|
||||||
|
'INSERT INTO emails (recipient, sender, subject, body, received_at) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[recipient, sender, subject, body, received_at]
|
||||||
|
);
|
||||||
|
const newEmailId = result.insertId;
|
||||||
|
|
||||||
|
logger.info(`Email from <${sender}> to <${recipient}> saved with ID: ${newEmailId}`);
|
||||||
|
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save email:', error);
|
logger.error('Failed to save email:', {
|
||||||
// We should not exit the process here, but maybe throw the error
|
errorMessage: error.message,
|
||||||
// so the caller (SMTPServer) can handle it.
|
errorStack: error.stack
|
||||||
|
});
|
||||||
|
// Re-throw the error so the caller (SMTPServer) can handle it.
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { saveEmail };
|
module.exports = { saveEmail };
|
||||||
|
|
||||||
|
|||||||
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}
|
||||||
35
docker-compose.build.yml
Normal file
35
docker-compose.build.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
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: {}
|
||||||
56
docker-compose.full.yml
Normal file
56
docker-compose.full.yml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
services:
|
||||||
|
# 1. 后端服务 (Node.js + Express + SMTP Server)
|
||||||
|
backend:
|
||||||
|
image: registry.cn-hangzhou.aliyuncs.com/pull-image/email-unlimit-backend:latest
|
||||||
|
container_name: email-backend
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- compose.full.env
|
||||||
|
networks:
|
||||||
|
- email-network
|
||||||
|
ports:
|
||||||
|
# - "5182:5182"
|
||||||
|
- "25:25"
|
||||||
|
|
||||||
|
# 2. 数据库服务 (MySQL)
|
||||||
|
mysql:
|
||||||
|
image: registry.cn-hangzhou.aliyuncs.com/pull-image/mysql:8.0
|
||||||
|
container_name: email-mysql
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
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
|
||||||
|
ports:
|
||||||
|
- "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:
|
||||||
|
- backend
|
||||||
|
- mysql
|
||||||
|
networks:
|
||||||
|
- email-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
email-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql-data: {}
|
||||||
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,21 +1,35 @@
|
|||||||
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: email-backend-container
|
container_name: email-backend
|
||||||
restart: always
|
restart: always
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
ports:
|
ports:
|
||||||
- "5182:5182" # API port
|
- "5182:5182" # API port
|
||||||
- "25:25" # SMTP port
|
- "25:25" # SMTP port
|
||||||
environment:
|
env_file:
|
||||||
- DB_HOST=43.143.145.172 # 替换为您的外部数据库主机名或IP
|
- compose.env
|
||||||
- DB_USER=root # 替换为您的数据库用户名
|
networks:
|
||||||
- DB_PASSWORD=kyff145972 # 替换为您的数据库密码
|
- email-network
|
||||||
- DB_NAME=maildb # 替换为您的数据库名称
|
|
||||||
|
# 2. 数据库服务 (MySQL)
|
||||||
|
mysql:
|
||||||
|
image: registry.cn-hangzhou.aliyuncs.com/pull-image/mysql:8.0 # 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:
|
networks:
|
||||||
- email-network
|
- email-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
email-network:
|
email-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql-data: {}
|
||||||
|
|||||||
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,10 +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 %>logo.svg" type="image/svg+xml">
|
<link rel="icon" href="/logo.svg" type="image/svg+xml">
|
||||||
<link rel="manifest" href="<%= BASE_URL %>manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<meta name="theme-color" content="#000000">
|
<meta name="theme-color" content="#000000">
|
||||||
<link rel="apple-touch-icon" href="<%= BASE_URL %>img/icons/apple-touch-icon.png">
|
<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>
|
||||||
@@ -15,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,16 +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",
|
"axios": "^1.11.0",
|
||||||
"register-service-worker": "^1.7.2",
|
"element-plus": "^2.10.4",
|
||||||
"vue": "^3.5.18"
|
"pinia": "^3.0.3",
|
||||||
|
"vue": "^3.5.18",
|
||||||
|
"vue-i18n": "^11.1.11",
|
||||||
|
"vue-router": "^4.5.1",
|
||||||
|
"ws": "^8.18.3",
|
||||||
|
"marked": "^13.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vue/cli-plugin-pwa": "~5.0.0",
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
"@vue/cli-service": "~5.0.8"
|
"vite": "^5.3.1",
|
||||||
|
"vite-plugin-pwa": "^0.20.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,264 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="app-header">
|
<AppHeader />
|
||||||
<div class="logo">Email Unlimit</div>
|
<router-view />
|
||||||
<nav class="nav-links">
|
|
||||||
<a href="https://gitea.shenjianl.cn/shenjianZ/email-unlimit" target="_blank">Gitee</a>
|
|
||||||
<a href="#" @click.prevent="showHowItWorks">How it Works</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main id="app-main">
|
|
||||||
<section class="hero-section">
|
|
||||||
<h1>您的专属临时邮箱,无限且私密</h1>
|
|
||||||
<p>输入任何<code>@shenjianl.cn</code>地址,立即在此查看收件箱。</p>
|
|
||||||
<form @submit.prevent="fetchMessages" class="input-group">
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
v-model="recipient"
|
|
||||||
class="email-input"
|
|
||||||
placeholder="输入您的临时邮箱地址..."
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button @click="copyEmail" type="button" class="btn-copy" title="复制地址">
|
|
||||||
<span v-if="copyStatus === 'copied'">✓</span>
|
|
||||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
||||||
<path d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"/>
|
|
||||||
<path d="M2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h-1v1a.5.5 0 0 1-.5.5H2.5a.5.5 0 0 1-.5-.5V6.5a.5.5 0 0 1 .5-.5H3v-1z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button @click="generateRandomEmail" type="button" class="btn btn-secondary">随机生成</button>
|
|
||||||
<button type="submit" class="btn btn-primary">查看收件箱</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="inbox-section">
|
|
||||||
<div class="inbox-container">
|
|
||||||
<div class="message-list">
|
|
||||||
<div class="message-list-header">
|
|
||||||
<h2>收件箱</h2>
|
|
||||||
<button @click="fetchMessages" class="refresh-btn" title="刷新">
|
|
||||||
↻
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="loading" class="loading-state">正在加载...</div>
|
|
||||||
<div v-else-if="messages.length === 0" class="empty-state">暂无邮件</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>请从左侧选择一封邮件查看</p>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<div class="message-content-header">
|
|
||||||
<h3>{{ selectedMessage.subject }}</h3>
|
|
||||||
<p><strong>发件人:</strong> {{ selectedMessage.sender }}</p>
|
|
||||||
<p><strong>收件人:</strong> {{ selectedMessage.recipient }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="message-body">
|
|
||||||
{{ selectedMessage.body }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- How it Works Modal -->
|
|
||||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
|
||||||
<div class="modal-content">
|
|
||||||
<button @click="closeModal" class="close-btn">×</button>
|
|
||||||
<h3>工作原理</h3>
|
|
||||||
<ol>
|
|
||||||
<li>在上面的输入框中,随意编造一个以<code>@{{ domain }}</code>结尾的邮箱地址。</li>
|
|
||||||
<li>使用这个地址去注册任何网站或接收邮件。</li>
|
|
||||||
<li>在这里输入您刚刚使用的地址,点击“查看收件箱”,即可看到邮件。</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, onMounted } from 'vue';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import AppHeader from './components/AppHeader.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'App',
|
name: 'App',
|
||||||
|
components: {
|
||||||
|
AppHeader,
|
||||||
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const recipient = ref('');
|
// 在根组件中建立 i18n 上下文,以供所有子路由继承
|
||||||
const messages = ref([]);
|
useI18n();
|
||||||
const selectedMessage = ref(null);
|
|
||||||
const loading = ref(false);
|
|
||||||
const showModal = ref(false);
|
|
||||||
const copyStatus = ref('idle'); // 'idle' | 'copied'
|
|
||||||
// !!! 生产环境<E78EAF><E5A283>要提示 !!!
|
|
||||||
// 请务必将下面的 'yourdomain.com' 替换为您的真实域名
|
|
||||||
const domain = 'shenjianl.cn';
|
|
||||||
|
|
||||||
const fetchMessages = async () => {
|
|
||||||
if (!recipient.value) {
|
|
||||||
alert('请输入一个邮箱地址');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading.value = true;
|
|
||||||
selectedMessage.value = null; // Clear selected message on new fetch
|
|
||||||
try {
|
|
||||||
// API URL 已修改为相对路径,以适配 Nginx 反向代理
|
|
||||||
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;
|
|
||||||
// Automatically select the first message if available
|
|
||||||
if (messages.value.length > 0) {
|
|
||||||
selectedMessage.value = messages.value[0];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch messages:', error);
|
|
||||||
alert('无法获取邮件,请检查后端服务和 Nginx 配置是否正常。');
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectMessage = (message) => {
|
|
||||||
selectedMessage.value = message;
|
|
||||||
};
|
|
||||||
|
|
||||||
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)];
|
|
||||||
|
|
||||||
// Randomly pick 2 lists to combine words from
|
|
||||||
const listA = getRandomItem(allLists);
|
|
||||||
const listB = getRandomItem(allLists);
|
|
||||||
|
|
||||||
const word1 = getRandomItem(listA);
|
|
||||||
const word2 = getRandomItem(listB);
|
|
||||||
|
|
||||||
const number = Math.floor(Math.random() * 9000) + 1000; // Random 4-digit number
|
|
||||||
|
|
||||||
// Randomly choose a separator
|
|
||||||
const separators = ['.', '-', '_', ''];
|
|
||||||
const separator = getRandomItem(separators);
|
|
||||||
|
|
||||||
let prefix;
|
|
||||||
if (word1 === word2) {
|
|
||||||
// Avoids "alex.alex1234" if the same list and word are picked
|
|
||||||
prefix = `${word1}${number}`;
|
|
||||||
} else {
|
|
||||||
prefix = `${word1}${separator}${word2}${number}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
recipient.value = `${prefix}@${domain}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyEmail = () => {
|
|
||||||
if (!recipient.value) return;
|
|
||||||
|
|
||||||
const textToCopy = recipient.value;
|
|
||||||
|
|
||||||
// Modern browsers in secure contexts
|
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
|
||||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
|
||||||
showCopySuccess();
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('Modern copy failed: ', err);
|
|
||||||
fallbackCopy(textToCopy); // Try fallback on error
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback for older browsers or insecure contexts
|
|
||||||
fallbackCopy(textToCopy);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fallbackCopy = (text) => {
|
|
||||||
const textArea = document.createElement('textarea');
|
|
||||||
textArea.value = text;
|
|
||||||
|
|
||||||
// Make the textarea out of sight
|
|
||||||
textArea.style.position = 'fixed';
|
|
||||||
textArea.style.top = '-9999px';
|
|
||||||
textArea.style.left = '-9999px';
|
|
||||||
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.focus();
|
|
||||||
textArea.select();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const successful = document.execCommand('copy');
|
|
||||||
if (successful) {
|
|
||||||
showCopySuccess();
|
|
||||||
} else {
|
|
||||||
throw new Error('Fallback copy was unsuccessful');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Fallback copy failed: ', err);
|
|
||||||
alert('复制失败!');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
};
|
|
||||||
|
|
||||||
const showCopySuccess = () => {
|
|
||||||
copyStatus.value = 'copied';
|
|
||||||
setTimeout(() => {
|
|
||||||
copyStatus.value = 'idle';
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const showHowItWorks = () => {
|
|
||||||
showModal.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
showModal.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
recipient,
|
|
||||||
messages,
|
|
||||||
selectedMessage,
|
|
||||||
loading,
|
|
||||||
showModal,
|
|
||||||
copyStatus,
|
|
||||||
domain,
|
|
||||||
fetchMessages,
|
|
||||||
selectMessage,
|
|
||||||
generateRandomEmail,
|
|
||||||
copyEmail,
|
|
||||||
showHowItWorks,
|
|
||||||
closeModal,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap');
|
||||||
/* Global Styles & Resets */
|
/* Global Styles & Resets */
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
@@ -5,7 +6,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap');
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--primary-purple: #6d28d9;
|
--primary-purple: #6d28d9;
|
||||||
@@ -65,7 +66,6 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-links a {
|
.nav-links a {
|
||||||
margin-left: 2rem;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--dark-grey);
|
color: var(--dark-grey);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -182,6 +182,15 @@ body {
|
|||||||
background-color: rgba(255, 255, 255, 0.3);
|
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 */
|
||||||
.inbox-section {
|
.inbox-section {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -201,8 +210,11 @@ body {
|
|||||||
|
|
||||||
.message-list {
|
.message-list {
|
||||||
width: 35%;
|
width: 35%;
|
||||||
border-right: 1px solid var(--medium-grey);
|
background-color: #ffffff;
|
||||||
padding-right: 1.5rem;
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-list-header {
|
.message-list-header {
|
||||||
@@ -217,12 +229,36 @@ body {
|
|||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn {
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions button {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1.5rem;
|
|
||||||
color: var(--dark-grey);
|
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 {
|
.message-item {
|
||||||
@@ -245,6 +281,7 @@ body {
|
|||||||
|
|
||||||
.message-item .from {
|
.message-item .from {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item .subject {
|
.message-item .subject {
|
||||||
@@ -256,6 +293,11 @@ body {
|
|||||||
|
|
||||||
.message-detail {
|
.message-detail {
|
||||||
width: 65%;
|
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 {
|
.empty-state, .loading-state {
|
||||||
@@ -278,12 +320,14 @@ body {
|
|||||||
.message-content-header p {
|
.message-content-header p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--dark-grey);
|
color: var(--dark-grey);
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-body {
|
.message-body {
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
@@ -433,4 +477,24 @@ body {
|
|||||||
width: 95%;
|
width: 95%;
|
||||||
padding: 1.5rem;
|
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>
|
||||||
103
frontend/src/i18n/index.js
Normal file
103
frontend/src/i18n/index.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
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: '收件人',
|
||||||
|
received_at: '收件时间',
|
||||||
|
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',
|
||||||
|
received_at: 'Received At',
|
||||||
|
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,6 +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 './assets/main.css' // 引入新的全局样式文件
|
import router from './router'
|
||||||
import './registerServiceWorker'
|
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')
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
|
|
||||||
import { register } from 'register-service-worker'
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
register(`${process.env.BASE_URL}service-worker.js`, {
|
|
||||||
ready () {
|
|
||||||
console.log(
|
|
||||||
'App is being served from cache by a service worker.\n' +
|
|
||||||
'For more details, visit https://goo.gl/AFskqB'
|
|
||||||
)
|
|
||||||
},
|
|
||||||
registered () {
|
|
||||||
console.log('Service worker has been registered.')
|
|
||||||
},
|
|
||||||
cached () {
|
|
||||||
console.log('Content has been cached for offline use.')
|
|
||||||
},
|
|
||||||
updatefound () {
|
|
||||||
console.log('New content is downloading.')
|
|
||||||
},
|
|
||||||
updated () {
|
|
||||||
console.log('New content is available; please refresh.')
|
|
||||||
},
|
|
||||||
offline () {
|
|
||||||
console.log('No internet connection found. App is running in offline mode.')
|
|
||||||
},
|
|
||||||
error (error) {
|
|
||||||
console.error('Error during service worker registration:', error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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 };
|
||||||
437
frontend/src/views/Home.vue
Normal file
437
frontend/src/views/Home.vue
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
<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>
|
||||||
|
<p><strong>{{ $t('home.received_at') }}:</strong> {{ selectedMessage.received_at }}</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,21 +0,0 @@
|
|||||||
const { defineConfig } = require('@vue/cli-service')
|
|
||||||
|
|
||||||
module.exports = defineConfig({
|
|
||||||
transpileDependencies: true,
|
|
||||||
// devServer 代理配置已移除,所有代理均由外部 Nginx 处理。
|
|
||||||
pwa: {
|
|
||||||
name: 'EmailUnlimit',
|
|
||||||
themeColor: '#000000',
|
|
||||||
msTileColor: '#000000',
|
|
||||||
appleMobileWebAppCapable: 'yes',
|
|
||||||
appleMobileWebAppStatusBarStyle: 'black',
|
|
||||||
|
|
||||||
// 配置 workbox-webpack-plugin
|
|
||||||
workboxPluginMode: 'GenerateSW',
|
|
||||||
workboxOptions: {
|
|
||||||
// swSrc is required in InjectManifest mode.
|
|
||||||
// swSrc: 'dev/sw.js',
|
|
||||||
// ...other Workbox options...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
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: 103 KiB |
Reference in New Issue
Block a user