Compare commits

32 Commits

Author SHA1 Message Date
f785e7defc feat: sample.png 2025-08-02 11:28:37 +08:00
742aa4d2fc feat: Display received time and fix timestamp timezone conversion. 2025-08-02 11:21:32 +08:00
9ab3621658 feat: Display received time and fix timestamp timezone conversion. 2025-08-02 11:20:58 +08:00
ae870fb601 feat: saveEmail.js ,不在向 raw 字段插入数据 2025-08-02 10:55:29 +08:00
77030cc8fc feat(docker): 安装 tzdata 并设置 TZ=Asia/Shanghai 以让 Node.js 使用上海时区 2025-08-02 10:49:24 +08:00
bb25928a8e feat(docker): 安装 tzdata 并设置 TZ=Asia/Shanghai 以让 Node.js 使用上海时区 2025-08-02 09:57:27 +08:00
3ad3849fde feat:fix README.md 2025-07-31 11:48:45 +08:00
99504e53ea feat:fix README.md 2025-07-31 11:46:43 +08:00
4787c9e2e0 feat:fix README.md 2025-07-31 11:45:40 +08:00
f8a4fb3561 feat:fix README.md 2025-07-31 11:44:51 +08:00
eecac11240 feat:fix docker-compose 2025-07-31 11:28:20 +08:00
9ceb96d9c7 feat:fix docker-compose 2025-07-31 11:00:22 +08:00
5b7cc39496 feat:fix docker-compose.full.yml 2025-07-31 09:05:09 +08:00
a7d8774157 feat:fix docker-compose.full.yml 2025-07-31 08:44:10 +08:00
91b3bc9640 feat:add certs dir 2025-07-31 08:13:51 +08:00
2b84fffacb feat:fix docker-compose-full.yml 2025-07-31 07:39:19 +08:00
d9a744dda4 nothing 2025-07-30 18:13:03 +08:00
f39b1f0246 feat:fix frontend .env 2025-07-30 18:06:20 +08:00
90ec3e5fc5 nothing 2025-07-30 16:32:58 +08:00
1f01ff97ad feat: fix WebSocket connection timeout 600s,overwrite 60s timeout 2025-07-30 16:23:10 +08:00
5e89bff405 feat: fix compose.env and compsoe.yml 2025-07-30 14:15:15 +08:00
a681494b82 feat: fix compose.env 2025-07-30 14:11:44 +08:00
2994a48e19 feat: fix compose.yml ;add frontend .env properties 2025-07-30 13:53:21 +08:00
0e267b923c feat: fix compose.yml 2025-07-29 22:12:21 +08:00
4b00824463 feat: fix compose.yml 2025-07-29 22:11:41 +08:00
eb5150dc15 feat: fix compose yml 2025-07-29 16:58:50 +08:00
7ee074249f feat: fix backend sql error;fix frontend i18n display error 2025-07-29 15:35:34 +08:00
32320e1cb0 feat: Implement frontend Markdown rendering. fix: Refined email view styling, includingelement spacing and layout, and added a dev-only sample message. 2025-07-29 13:12:39 +08:00
8c649adf93 feat: Enhance backend with request/SQL logging via morgan/winston and fix frontend by correctly rendering email Markdown content using the 'marked' library. 2025-07-29 12:44:56 +08:00
c38a4f0f62 feat: add docker compose full deploy 2025-07-29 08:39:20 +08:00
a186439b84 feat: add switch with zh|en 2025-07-28 23:36:33 +08:00
7bfb909532 feat: fix sender|recipient display 2025-07-28 22:51:23 +08:00
39 changed files with 1698 additions and 459 deletions

4
.gitignore vendored
View File

@@ -3,3 +3,7 @@
/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

View File

@@ -1,4 +1,4 @@
1.在终端执行命令时使用适合Windows 的命令 1.在终端执行命令时使用适合Windows的命令,不要使用linux的命令
2.始终使用中文回答 2.始终使用中文回答

21
LICENSE Normal file
View File

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

156
README.md
View File

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

View File

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

View File

@@ -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}`);
}); });

View File

@@ -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
View File

@@ -0,0 +1,4 @@
const EventEmitter = require('events');
class MyEmitter extends EventEmitter {}
const emitter = new MyEmitter();
module.exports = emitter;

View File

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

42
backend/logger.js Normal file
View 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;

View File

@@ -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"
} }
} }

View File

@@ -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
View File

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

9
compose.env Normal file
View 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
View 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
View 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
View 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
View 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

View File

@@ -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
View File

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

View File

@@ -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>

View File

@@ -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"
} }
} }

View File

@@ -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="刷新">
&#x21bb;
</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">&times;</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>

View File

@@ -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;
@@ -434,3 +478,23 @@ body {
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)); /* 悬停时加深渐变 */
}

View 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

View 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
View 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;

View File

@@ -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')

View File

@@ -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)
}
})
}

View 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;

View 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);
},
},
});

View 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
View 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
View 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>

View 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
View 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
}
})

View File

@@ -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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB