Compare commits

4 Commits

Author SHA1 Message Date
59d0ae2a88 feat: fix logo disply 2025-07-28 16:47:34 +08:00
82659c600a feat: fix android display 2025-07-28 15:46:43 +08:00
f1b39d22a5 feat: fix backend some error;fix frontend display error 2025-07-28 14:14:11 +08:00
eeb3b4b0df deploy success - first version successed 2025-07-28 13:17:54 +08:00
15 changed files with 870 additions and 596 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/backend/node_modules /backend/node_modules
/frontend/node_modules /frontend/node_modules
/backend/package-lock.json /backend/package-lock.json
/frontend/package-lock.json /frontend/package-lock.json
/frontend/dist

184
README.md
View File

@@ -1,95 +1,123 @@
# Email Unlimit Project (临时邮件项目) # 轻量级临时邮件项目 (Email Unlimit)
## 最终架构 (单一 Docker Compose + 宿主机 Nginx) 本项目是一个轻量级的、可自托管的临时邮件解决方案。它允许您使用自己的域名接收邮件,并通过一个简洁的网页界面来查看这些邮件。
本项目采用生产环境推荐的架构:所有服务(包括您的应用和完整的 Mailu 邮件套件)都由一个 `docker-compose.full.yml` 文件统一管理,而入口的反向代理则由 **宿主机上的 Nginx** 负责 `Mailu` 等复杂的邮件套件不同,本项目采用了一个极简的 Node.js 服务来直接接收和处理邮件,部署和维护都非常简单
- **宿主机 Nginx:** 作为项目的唯一公共入口 (`main.shenjianl.cn`)。它负责: ## 技术架构
1. 处理所有外部流量和 SSL 加密(推荐)。
2. 将对域名根路径 (`/`) 的访问反向代理到 **前端 Docker 容器** (`localhost:5181`)。
3. 将对 `/api` 路径的访问反向代理到 **后端 Docker 容器** (`localhost:5182`)。
4. 将对 `/mailu` 路径的访问反向代理到 **Mailu Admin UI** (`localhost:80`)。
- **Docker 容器 (由 `docker-compose.full.yml` 管理):** * **前端 (Frontend)**: 使用 Vue.js 构建的单页面应用,负责展示收到的邮件列表。
- **您的应用:** `frontend`, `backend`, `mysql` * **后端 (Backend)**:
- **Mailu 套件:** `front` (Mailu Nginx), `admin`, `smtp`, `imap`, `redis` 等全套服务。 * 使用 Node.js 和 Express 搭建的 API 服务
* 内置一个轻量级的 SMTP 服务器 (`smtp-server`),用于直接接收邮件,无需外部邮件服务。
* 负责将收到的邮件解析并存入数据库。
* **数据库 (Database)**: 需要一个外部的 MySQL 数据库来存储邮件信息。
* **部署 (Deployment)**: 后端服务通过 Docker Compose 进行容器化部署,前端静态文件由宿主机的 Nginx 提供服务。
--- ## 部署要求
## 如何运行 在开始之前<EFBFBD><EFBFBD>确保您已准备好以下环境
### 步骤 1: 准备工作 1. 一台拥有公网 IP 的 Linux 服务器。
2. 一个您自己的域名。
3. 服务器上已安装 `Docker``Docker Compose`
4. 服务器上已安装 `Nginx`
5. 一个可用的外部 MySQL 数据库,并已创建好数据库。
6. 本地开发环境已安装 `Node.js``npm` (用于构建前端)。
1. **安装 Docker**: 确保您的系统中已安装 Docker 和 Docker Compose。 ## 部署步骤
2. **配置 Mailu**: 打开 `mailu.env` 文件,**务必修改** `SECRET_KEY``ADMIN_PASSWORD` 为安全的值。域名和数据库配置已预填。
3. **配置 DNS**: 根据 `info.md` 中的 DNS 记录示例,在您的域名提供商处完成 SPF、DKIM 和 DMARC 的配置。
### 步骤 2: 配置宿主机 Nginx ### 步骤 1: 配置域名 DNS
将下面的配置块添加到您宿主机的 Nginx 中。这份配置统一处理了对您的应用和 Mailu 管理后台的访问 要让邮件能正确发送到您的服务器,您必须配置域名的 `MX` 记录
```nginx 1. 登录您的域名注册商(如 GoDaddy, Cloudflare 等)。
# /etc/nginx/conf.d/main.shenjianl.cn.conf 2. 找到 DNS 解析设置。
3. 添加一条 `MX` 记录:
* **类型 (Type)**: `MX`
* **名称 (Name/Host)**: `@` (代表您的根域名)
* **值 (Value/Points to)**: `您的服务器公网 IP 地址`
* **优先级 (Priority)**: `10`
server { > **注意**: DNS 记录生效可能需要几分钟到几小时。
listen 80;
server_name main.shenjianl.cn;
# --- 推荐配置 SSL --- ### 步骤 2: 部署后端服务
# listen 443 ssl http2;
# server_name main.shenjianl.cn;
# ssl_certificate /path/to/your/fullchain.pem;
# ssl_certificate_key /path/to/your/privkey.pem;
# --------------------
# 反向代理到前端 Vue.js 容器 1. 将本项目克隆或上传到您的服务器。
location / { 2. 进入项目根目录,编辑 `docker-compose.yml` 文件。
proxy_pass http://localhost:5181; 3. **填写您的外部数据库连接信息**
proxy_set_header Host $host; ```yaml
proxy_set_header X-Real-IP $remote_addr; services:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; backend:
proxy_set_header X-Forwarded-Proto $scheme; # ...
proxy_http_version 1.1; environment:
proxy_set_header Upgrade $http_upgrade; - DB_HOST=your_external_db_host # 替换为您的外部数据库主机名或IP
proxy_set_header Connection "upgrade"; - DB_USER=your_external_db_user # 替换为您的数据库用户名
- DB_PASSWORD=your_external_db_password # 替换为您的数据库密码
- DB_NAME=your_external_db_name # 替换为您的数据库名称
```
4. 在 `backend` 目录下有一个 `init.sql` 文件,请手动将其中的 SQL 命令在您的外部数据库中执行,以创建所需的表。
5. 在项目根目录,使用 Docker Compose 启动后端服务:
```bash
docker-compose up -d --build
```
此命令会构建并以后台模式启动后端容器。服务将监听服务器的 `5182` (API) 和 `25` (SMTP) 端口。
### 步骤 3: 构建和部署前端
1. **在您的本地开发机上**,进入 `frontend` 目录。
2. 安装依赖并构建静态文件:
```bash
npm install
npm run build
```
这将在 `frontend/dist` 目录下生成所有用于部署的静态文件。
3. 将 `frontend/dist` 目录下的 **所有文件** 上传到您服务器的指定位置,例如 `/var/www/email-unlimit`。
### 步骤 4: 配置宿主机 Nginx
1. 在服务器上,为您的应用创建一个 Nginx 配置文件,例如 `/etc/nginx/sites-available/email.conf`。
2. 将以下配置写入该文件。请务必将 `your_domain.com` 和 `root` 路径修改为您自己的配置。
```nginx
server {
listen 443 ssl;
server_name mail.shenjianl.cn; # 替换为您的域名
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
```
# 反向代理到后端 API 容器 ## 如何使用
location /api {
proxy_pass http://localhost:5182;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 反向代理到 Mailu Admin UI 和 Webmail 1. **访问您的网站**: 在浏览器中打开 `http://your_domain.com`。
# 注意Mailu 自己的 Nginx (front) 在 80 端口上运行 2. **发送测试邮件**: 使用任何邮箱客户端,向 `anything@your_domain.com` (例如 `test@your_domain.com`) 发送一封邮件。
location /mailu { 3. **查看邮件**: 在网站上输入您刚刚使用的收件人地址 (`anything@your_domain.com`),点击查询,即可看到收到的邮件。
proxy_pass http://localhost:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
**配置完成后,请重载或重启您的 Nginx 服务。**
### 步骤 3: 启动 Docker 服务
使用我们最终生成的 `docker-compose.full.yml` 文件来启动所有服务。
```bash
# 在项目根目录执行
docker-compose -f docker-compose.full.yml up -d --build
```
---
## 访问<E8AEBF><E997AE><EFBFBD>
- **主应用入口**: `http://main.shenjianl.cn`
- **Mailu 管理后台**: `http://main.shenjianl.cn/mailu`
- **Mailu Webmail**: `http://main.shenjianl.cn/mailu/webmail`
docker-compose up -d --build

View File

@@ -7,6 +7,14 @@ RUN npm install
COPY . . COPY . .
# Expose API port and SMTP port
EXPOSE 5182 EXPOSE 5182
EXPOSE 25
# Environment variables for database connection will be passed at runtime
# ENV DB_HOST=...
# ENV DB_USER=...
# ENV DB_PASSWORD=...
# ENV DB_NAME=...
CMD [ "node", "app.js" ] CMD [ "node", "app.js" ]

View File

@@ -1,9 +1,12 @@
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const db = require('./db'); const db = require('./db');
const { SMTPServer } = require('smtp-server');
const { saveEmail } = require('./saveEmail');
const app = express(); const app = express();
const port = 5182; const apiPort = 5182;
const smtpPort = 25;
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
@@ -17,7 +20,7 @@ app.get('/api/messages', async (req, res) => {
try { try {
const [rows] = await db.execute( const [rows] = await db.execute(
'SELECT id, sender, subject, body, received_at FROM emails WHERE recipient = ? ORDER BY received_at DESC', 'SELECT id, sender, recipient, subject, body, received_at FROM emails WHERE recipient = ? ORDER BY received_at DESC',
[recipient] [recipient]
); );
res.json(rows); res.json(rows);
@@ -27,6 +30,33 @@ app.get('/api/messages', async (req, res) => {
} }
}); });
app.listen(port, () => { // Start API server
console.log(`Backend server listening at http://localhost:${port}`); app.listen(apiPort, () => {
console.log(`Backend API server listening at http://localhost:${apiPort}`);
});
// Configure and start SMTP server
const smtpServer = new SMTPServer({
authOptional: true,
disabledCommands: ['AUTH'],
onData(stream, session, callback) {
console.log('Receiving email...');
saveEmail(stream)
.then(() => {
console.log('Email processed and saved successfully.');
callback(); // Accept the message
})
.catch(err => {
console.error('Error processing email:', err);
callback(new Error('Failed to process email.'));
});
},
});
smtpServer.on('error', err => {
console.error('SMTP Server Error:', err.message);
});
smtpServer.listen(smtpPort, () => {
console.log(`SMTP server listening on port ${smtpPort}`);
}); });

View File

@@ -11,6 +11,6 @@
"mysql2": "^3.14.2", "mysql2": "^3.14.2",
"mailparser": "^3.7.4", "mailparser": "^3.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"get-stream": "^9.0.1" "smtp-server": "^3.13.4"
} }
} }

View File

@@ -1,16 +1,25 @@
#!/usr/bin/env node
const { simpleParser } = require('mailparser'); const { simpleParser } = require('mailparser');
const db = require('./db'); const db = require('./db');
// Using require for get-stream v5 as it's CommonJS // Helper function to convert stream to buffer
const getStream = require('get-stream'); function streamToBuffer(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
});
}
async function saveEmail(rawEmail) { async function saveEmail(stream) {
try { try {
const parsed = await simpleParser(rawEmail); // First, buffer the entire stream
const emailBuffer = await streamToBuffer(stream);
// Now, parse the buffered email content
const parsed = await simpleParser(emailBuffer);
const rawEmail = emailBuffer.toString();
// Ensure 'to' and 'from' objects exist before accessing 'text'
const recipient = parsed.to ? parsed.to.text : 'undisclosed-recipients'; const recipient = parsed.to ? parsed.to.text : 'undisclosed-recipients';
const sender = parsed.from ? parsed.from.text : 'unknown-sender'; const sender = parsed.from ? parsed.from.text : 'unknown-sender';
const subject = parsed.subject; const subject = parsed.subject;
@@ -21,7 +30,7 @@ async function saveEmail(rawEmail) {
[recipient, sender, subject, body, rawEmail] [recipient, sender, subject, body, rawEmail]
); );
console.log(`Email saved with ID: ${result.insertId}`); console.log(`Email from <${sender}> to <${recipient}> saved with ID: ${result.insertId}`);
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) {
@@ -34,21 +43,11 @@ async function saveEmail(rawEmail) {
} }
} catch (error) { } catch (error) {
console.error('Failed to save email:', error); console.error('Failed to save email:', error);
// Exit with an error code to signal failure to the calling process (e.g., Mailu) // We should not exit the process here, but maybe throw the error
process.exit(1); // so the caller (SMTPServer) can handle it.
throw error;
} }
} }
(async () => { module.exports = { saveEmail };
try {
const rawEmail = await getStream(process.stdin);
if (rawEmail && rawEmail.length > 0) {
await saveEmail(rawEmail);
} else {
console.log('Received empty input, no email to save.');
}
} catch (error) {
console.error('Error reading from stdin:', error);
process.exit(1);
}
})();

View File

@@ -1,118 +0,0 @@
# docker-compose.full.yml
# 最终合并版本:包含您的应用 (frontend, backend) 和完整的 Mailu 服务套件。
version: '3.3'
services:
# -----------------------------------------
# 您的应用服务
# -----------------------------------------
backend:
build: ./backend
container_name: mail_backend
restart: always
environment:
DB_HOST: "43.143.145.172"
DB_USER: "root"
DB_PASSWORD: "kyff145972"
DB_NAME: "maildb"
ports:
- "5182:5182"
frontend:
build: ./frontend
container_name: mail_frontend
restart: always
ports:
- "5181:8080"
depends_on:
- backend
# -----------------------------------------
# Mailu 官方服务套件
# -----------------------------------------
redis:
image: redis:alpine
restart: always
volumes:
- "mailu_redis:/data"
front:
image: ghcr.io/mailu/nginx:2.0
restart: always
env_file: mailu.env
hostname: mail.shenjianl.cn
volumes:
- "mailu_certs:/certs"
- "mailu_overrides_nginx:/overrides"
resolver:
image: ghcr.io/mailu/unbound:2.0
restart: always
env_file: mailu.env
hostname: mail.shenjianl.cn
dns:
- 8.8.8.8
- 1.1.1.1
# --- 用于调试的临时修改 ---
entrypoint: /bin/sh
command: -c "sleep 3600"
admin:
image: ghcr.io/mailu/admin:2.0
restart: always
env_file: mailu.env
hostname: mail.shenjianl.cn
volumes:
- "mailu_data:/data"
- "mailu_dkim:/dkim"
depends_on:
- redis
imap:
image: ghcr.io/mailu/dovecot:2.0
restart: always
env_file: mailu.env
hostname: mail.shenjianl.cn
volumes:
- "mailu_mail:/mail"
- "mailu_overrides_dovecot:/overrides"
depends_on:
- front
smtp:
image: ghcr.io/mailu/postfix:2.0
restart: always
env_file: mailu.env
hostname: mail.shenjianl.cn
volumes:
- "mailu_overrides_postfix:/overrides"
depends_on:
- front
- resolver
antispam:
image: ghcr.io/mailu/rspamd:2.0
restart: always
env_file: mailu.env
hostname: mail.shenjianl.cn
volumes:
- "mailu_filter:/var/lib/rspamd"
- "mailu_overrides_rspamd:/etc/rspamd/override.d"
depends_on:
- front
volumes:
mailu_data:
mailu_certs:
mailu_dkim:
mailu_filter:
mailu_mail:
mailu_redis:
mailu_overrides_nginx:
mailu_overrides_dovecot:
mailu_overrides_postfix:
mailu_overrides_rspamd:

View File

@@ -1,115 +1,21 @@
# docker-compose.full.yml
# 最终合并版本:包含您的应用 (frontend, backend) 和完整的 Mailu 服务套件。
version: '3.3' version: '3.3'
services: services:
# -----------------------------------------
# 您的应用服务
# -----------------------------------------
backend: backend:
build: ./backend build: ./backend
container_name: mail_backend container_name: email-backend-container
restart: always restart: always
ports:
- "5182:5182" # API port
- "25:25" # SMTP port
environment: environment:
DB_HOST: "43.143.145.172" - DB_HOST=43.143.145.172 # 替换为您的外部数据库主机名或IP
DB_USER: "root" - DB_USER=root # 替换为您的数据库用户名
DB_PASSWORD: "kyff145972" - DB_PASSWORD=kyff145972 # 替换为您的数据库密码
DB_NAME: "maildb" - DB_NAME=maildb # 替换为您的数据库名称
ports: networks:
- "5182:5182" - email-network
frontend: networks:
build: ./frontend email-network:
container_name: mail_frontend driver: bridge
restart: always
ports:
- "5181:8080"
depends_on:
- backend
# -----------------------------------------
# Mailu 官方服务套件
# -----------------------------------------
redis:
image: redis:alpine
restart: always
volumes:
- "mailu_redis:/data"
front:
image: ghcr.io/mailu/nginx:2.0
restart: always
env_file: mailu.env
hostname: mail.shenjianl.cn
volumes:
- "mailu_certs:/certs"
- "mailu_overrides_nginx:/overrides"
resolver:
image: ghcr.io/mailu/unbound:2.0
restart: always
env_file: mailu.env
hostname: mail.shenjianl.cn
dns:
- 8.8.8.8
- 1.1.1.1
admin:
image: ghcr.io/mailu/admin:2.0
restart: always
env_file: mailu.env
hostname: mail.shenjianl.cn
volumes:
- "mailu_data:/data"
- "mailu_dkim:/dkim"
depends_on:
- redis
imap:
image: ghcr.io/mailu/dovecot:2.0
restart: always
env_file: mailu.env
hostname: mail.shenjianl.cn
volumes:
- "mailu_mail:/mail"
- "mailu_overrides_dovecot:/overrides"
depends_on:
- front
smtp:
image: ghcr.io/mailu/postfix:2.0
restart: always
env_file: mailu.env
hostname: mail.shenjianl.cn
volumes:
- "mailu_overrides_postfix:/overrides"
depends_on:
- front
- resolver
antispam:
image: ghcr.io/mailu/rspamd:2.0
restart: always
env_file: mailu.env
hostname: mail.shenjianl.cn
volumes:
- "mailu_filter:/var/lib/rspamd"
- "mailu_overrides_rspamd:/etc/rspamd/override.d"
depends_on:
- front
volumes:
mailu_data:
mailu_certs:
mailu_dkim:
mailu_filter:
mailu_mail:
mailu_redis:
mailu_overrides_nginx:
mailu_overrides_dovecot:
mailu_overrides_postfix:
mailu_overrides_rspamd:

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> <link rel="icon" href="<%= BASE_URL %>logo.svg" type="image/svg+xml">
<title>临时邮件</title> <title>临时邮件</title>
</head> </head>
<body> <body>

1
frontend/public/logo.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1753688160727" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3424" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M972.8 934.274509 998.4 908.54902 127.868001 908.54902C85.502225 908.54902 51.2 874.019706 51.2 831.263017L51.2 111.058823 25.6 136.784314 896.131998 136.784314C938.452011 136.784314 972.8 171.324278 972.8 213.860934L972.8 278.566694C972.8 292.77449 984.26151 304.292183 998.4 304.292183 1012.53849 304.292183 1024 292.77449 1024 278.566694L1024 213.860934C1024 142.916556 966.736828 85.333333 896.131998 85.333333L25.6 85.333333 0 85.333333 0 111.058823 0 831.263017C0 902.415639 57.205646 960 127.868001 960L998.4 960 1024 960 1024 934.274509 1024 457.69849C1024 443.490694 1012.53849 431.973001 998.4 431.973001 984.26151 431.973001 972.8 443.490694 972.8 457.69849L972.8 934.274509ZM512.651558 567.164817C520.64791 572.874498 531.187889 573.490406 539.788919 568.750601L1002.624789 313.693694C1015.021598 306.862133 1019.560139 291.225196 1012.761901 278.767619 1005.963665 266.310041 990.403006 261.749254 978.006197 268.580816L515.170327 523.637722 542.307689 525.223505 194.028065 276.539467C182.502788 268.310009 166.520953 271.027598 158.331639 282.609372 150.142325 294.191146 152.846657 310.251322 164.371935 318.480781L512.651558 567.164817Z" fill="#000000" p-id="3425"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,257 +1,264 @@
<template> <template>
<div id="app-container"> <header class="app-header">
<h1>临时邮箱</h1> <div class="logo">Email Unlimit</div>
<div class="email-generator"> <nav class="nav-links">
<input v-model="randomEmail" type="text" readonly @click="copyToClipboard" title="点击复制"/> <a href="https://gitea.shenjianl.cn/shenjianZ/email-unlimit" target="_blank">Gitee</a>
<button @click="generateRandomEmail">生成新地址</button> <a href="#" @click.prevent="showHowItWorks">How it Works</a>
</div> </nav>
</header>
<div class="inbox"> <main id="app-main">
<h2>收件箱 ({{ currentEmail }})</h2> <section class="hero-section">
<button @click="fetchMessages" :disabled="loading"> <h1>您的专属临时邮箱无限且私密</h1>
{{ loading ? '刷新中...' : '刷新邮件' }} <p>输入任何<code>@shenjianl.cn</code>地址立即在此查看收件箱</p>
</button> <form @submit.prevent="fetchMessages" class="input-group">
<div v-if="error" class="error">{{ error }}</div> <div class="input-wrapper">
<ul v-if="messages.length > 0" class="message-list"> <input
<li v-for="msg in messages" :key="msg.id" @click="selectMessage(msg)" :class="{ selected: selectedMessage && selectedMessage.id === msg.id }"> type="email"
<div class="sender"><strong>发件人:</strong> {{ msg.sender }}</div> v-model="recipient"
<div class="subject"><strong>主题:</strong> {{ msg.subject }}</div> class="email-input"
<div class="time">{{ new Date(msg.received_at).toLocaleString() }}</div> placeholder="输入您的临时邮箱地址..."
</li> required
</ul> />
<div v-else class="no-messages"> <button @click="copyEmail" type="button" class="btn-copy" title="复制地址">
<p>{{ loading ? '正在加载...' : '收件箱是空的。' }}</p> <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> </div>
</div> </section>
</main>
<div v-if="selectedMessage" class="message-view"> <!-- How it Works Modal -->
<h3>{{ selectedMessage.subject }}</h3> <div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<p><strong>发件人:</strong> {{ selectedMessage.sender }}</p> <div class="modal-content">
<p><strong>收件人:</strong> {{ currentEmail }}</p> <button @click="closeModal" class="close-btn">&times;</button>
<p><strong>时间:</strong> {{ new Date(selectedMessage.received_at).toLocaleString() }}</p> <h3>工作原理</h3>
<hr> <ol>
<div v-html="selectedMessage.body" class="email-body"></div> <li>在上面的输入框中随意编造一个以<code>@{{ domain }}</code>结尾的邮箱地址</li>
<li>使用这个地址去注册任何网站或接收邮件</li>
<li>在这里输入您刚刚使用的地址点击查看收件箱即可看到邮件</li>
</ol>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import axios from 'axios'; import { ref, onMounted } from 'vue';
const API_URL = '/api';
export default { export default {
name: 'App', name: 'App',
data() { setup() {
return { const recipient = ref('');
randomEmail: '', const messages = ref([]);
currentEmail: '', const selectedMessage = ref(null);
messages: [], const loading = ref(false);
selectedMessage: null, const showModal = ref(false);
loading: false, const copyStatus = ref('idle'); // 'idle' | 'copied'
error: null, // !!! 生产环境<E78EAF><E5A283>要提示 !!!
domain: 'shenjianl.cn', // 从 info.md 获取 // 请务必将下面的 'yourdomain.com' 替换为您的真实域名
refreshInterval: null const domain = 'shenjianl.cn';
};
}, const fetchMessages = async () => {
methods: { if (!recipient.value) {
generateRandomString(length) { alert('请输入一个邮箱地址');
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
},
generateRandomEmail() {
const name = this.generateRandomString(8);
this.randomEmail = `${name}@${this.domain}`;
this.currentEmail = this.randomEmail;
this.messages = [];
this.selectedMessage = null;
this.fetchMessages();
},
async fetchMessages() {
if (!this.currentEmail) {
return; return;
} }
this.loading = true; loading.value = true;
this.error = null; selectedMessage.value = null; // Clear selected message on new fetch
try { try {
const response = await axios.get(`${API_URL}/messages`, { // API URL 已修改为相对路径,以适配 Nginx 反向代理
params: { recipient: this.currentEmail } 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
}); });
this.messages = response.data; } else {
// 如果有新邮件,且当前选中了邮件,则更新选中的邮件内容 // Fallback for older browsers or insecure contexts
if (this.selectedMessage) { fallbackCopy(textToCopy);
const updatedSelected = this.messages.find(m => m.id === this.selectedMessage.id); }
if (updatedSelected) { };
this.selectedMessage = updatedSelected;
} else { const fallbackCopy = (text) => {
this.selectedMessage = null; // 如果邮件被删除,则取消选中 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) { } catch (err) {
this.error = '无法加载邮件。请检查后端服务是否运行。'; console.error('Fallback copy failed: ', err);
console.error(err); alert('复制失败!');
} finally {
this.loading = false;
} }
},
selectMessage(message) { document.body.removeChild(textArea);
this.selectedMessage = message; };
},
copyToClipboard() { const showCopySuccess = () => {
navigator.clipboard.writeText(this.randomEmail).then(() => { copyStatus.value = 'copied';
alert('邮箱地址已复制到剪贴板'); setTimeout(() => {
}, (err) => { copyStatus.value = 'idle';
console.error('Could not copy text: ', err); }, 2000);
}); };
},
startAutoRefresh() { const showHowItWorks = () => {
this.refreshInterval = setInterval(this.fetchMessages, 15000); // 每15秒刷新一次 showModal.value = true;
}, };
stopAutoRefresh() {
if (this.refreshInterval) { const closeModal = () => {
clearInterval(this.refreshInterval); showModal.value = false;
} };
}
return {
recipient,
messages,
selectedMessage,
loading,
showModal,
copyStatus,
domain,
fetchMessages,
selectMessage,
generateRandomEmail,
copyEmail,
showHowItWorks,
closeModal,
};
}, },
created() {
this.generateRandomEmail();
this.startAutoRefresh();
},
beforeUnmount() {
this.stopAutoRefresh();
}
}; };
</script> </script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f4f7f9;
color: #333;
margin: 0;
padding: 20px;
}
#app-container {
max-width: 900px;
margin: 0 auto;
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
h1, h2, h3 {
color: #2c3e50;
}
.email-generator {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.email-generator input {
flex-grow: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
background: #f9f9f9;
cursor: pointer;
}
button {
padding: 10px 15px;
border: none;
background-color: #42b983;
color: white;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
button:hover {
background-color: #36a374;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.inbox {
margin-bottom: 20px;
}
.message-list {
list-style: none;
padding: 0;
border: 1px solid #eee;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
}
.message-list li {
padding: 15px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background-color 0.2s;
}
.message-list li:last-child {
border-bottom: none;
}
.message-list li:hover {
background-color: #f9f9f9;
}
.message-list li.selected {
background-color: #e8f5e9;
}
.sender, .subject, .time {
margin-bottom: 5px;
}
.time {
font-size: 0.8em;
color: #777;
}
.no-messages {
text-align: center;
padding: 20px;
color: #777;
}
.message-view {
border: 1px solid #eee;
padding: 20px;
border-radius: 4px;
background: #fdfdfd;
}
.email-body {
margin-top: 15px;
white-space: pre-wrap;
word-wrap: break-word;
background-color: #fff;
padding: 1em;
border-radius: 4px;
border: 1px solid #ddd;
overflow: auto;
}
.error {
color: #e74c3c;
margin: 10px 0;
}
</style>

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1753688160727" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3424" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M972.8 934.274509 998.4 908.54902 127.868001 908.54902C85.502225 908.54902 51.2 874.019706 51.2 831.263017L51.2 111.058823 25.6 136.784314 896.131998 136.784314C938.452011 136.784314 972.8 171.324278 972.8 213.860934L972.8 278.566694C972.8 292.77449 984.26151 304.292183 998.4 304.292183 1012.53849 304.292183 1024 292.77449 1024 278.566694L1024 213.860934C1024 142.916556 966.736828 85.333333 896.131998 85.333333L25.6 85.333333 0 85.333333 0 111.058823 0 831.263017C0 902.415639 57.205646 960 127.868001 960L998.4 960 1024 960 1024 934.274509 1024 457.69849C1024 443.490694 1012.53849 431.973001 998.4 431.973001 984.26151 431.973001 972.8 443.490694 972.8 457.69849L972.8 934.274509ZM512.651558 567.164817C520.64791 572.874498 531.187889 573.490406 539.788919 568.750601L1002.624789 313.693694C1015.021598 306.862133 1019.560139 291.225196 1012.761901 278.767619 1005.963665 266.310041 990.403006 261.749254 978.006197 268.580816L515.170327 523.637722 542.307689 525.223505 194.028065 276.539467C182.502788 268.310009 166.520953 271.027598 158.331639 282.609372 150.142325 294.191146 152.846657 310.251322 164.371935 318.480781L512.651558 567.164817Z" fill="#000000" p-id="3425"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,436 @@
/* Global Styles & Resets */
*,
*::before,
*::after {
box-sizing: border-box;
}
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap');
:root {
--primary-purple: #6d28d9;
--light-purple: #8b5cf6;
--dark-purple: #5b21b6;
--light-grey: #f3f4f6;
--medium-grey: #e5e7eb;
--dark-grey: #4b5563;
--text-light: #ffffff;
--text-dark: #1f2937;
--border-radius: 12px;
--card-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background-color: #ffffff;
color: var(--text-dark);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden; /* Prevent horizontal scrolling */
}
/* App Container */
#app {
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
width: 100%;
box-sizing: border-box;
}
#app-main {
width: 100%;
max-width: 1400px;
padding: 0 2rem;
margin-bottom: 2rem;
}
/* Header */
.app-header {
width: 100%;
max-width: 1400px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
box-sizing: border-box;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-dark);
}
.nav-links a {
margin-left: 2rem;
text-decoration: none;
color: var(--dark-grey);
font-weight: 500;
transition: color 0.3s ease;
}
.nav-links a:hover {
color: var(--primary-purple);
}
/* Hero Section */
.hero-section {
width: 100%;
/* max-width is handled by app-main */
background-color: var(--primary-purple);
color: var(--text-light);
padding: 4rem 2rem;
border-radius: var(--border-radius);
text-align: center;
margin-bottom: 2rem;
box-sizing: border-box;
}
.hero-section h1 {
font-size: 2.5rem;
margin-top: 0;
margin-bottom: 1rem;
}
.hero-section p {
font-size: 1.125rem;
opacity: 0.9;
margin-bottom: 2rem;
}
.input-group {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.input-wrapper {
position: relative;
display: flex;
flex: 1;
max-width: 400px;
}
.email-input {
flex: 1;
/* top, right, bottom, left */
padding: 0.75rem 3rem 0.75rem 1rem;
border: 1px solid transparent;
border-radius: 8px;
font-size: 1rem;
width: 100%;
}
.btn-copy {
position: absolute;
top: 50%;
right: 0.5rem;
transform: translateY(-50%);
background: transparent;
border: none;
cursor: pointer;
color: var(--dark-grey);
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border-radius: 6px;
}
.btn-copy:hover {
background-color: var(--medium-grey);
}
.btn-copy span {
color: var(--primary-purple);
font-weight: bold;
}
.email-input:focus {
outline: none;
border-color: var(--light-purple);
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.3s ease;
}
.btn-primary {
background-color: var(--light-purple);
color: var(--text-light);
}
.btn-primary:hover {
background-color: var(--dark-purple);
}
.btn-secondary {
background-color: rgba(255, 255, 255, 0.2);
color: var(--text-light);
}
.btn-secondary:hover {
background-color: rgba(255, 255, 255, 0.3);
}
/* Inbox Section */
.inbox-section {
width: 100%;
/* max-width is handled by app-main */
background-color: var(--light-grey);
border-radius: var(--border-radius);
padding: 1.5rem;
box-shadow: var(--card-shadow);
box-sizing: border-box;
}
.inbox-container {
display: flex;
gap: 1.5rem;
min-height: 400px;
}
.message-list {
width: 35%;
border-right: 1px solid var(--medium-grey);
padding-right: 1.5rem;
}
.message-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.message-list-header h2 {
margin: 0;
font-size: 1.25rem;
}
.refresh-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.5rem;
color: var(--dark-grey);
}
.message-item {
padding: 1rem;
border-radius: 8px;
cursor: pointer;
border: 1px solid transparent;
border-left: 4px solid transparent;
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.message-item:hover {
background-color: var(--medium-grey);
}
.message-item.selected {
background-color: #ffffff;
border-left-color: var(--primary-purple);
}
.message-item .from {
font-weight: 700;
}
.message-item .subject {
color: var(--dark-grey);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.message-detail {
width: 65%;
}
.empty-state, .loading-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
color: var(--dark-grey);
}
.message-content-header {
margin-bottom: 1rem;
}
.message-content-header h3 {
margin: 0 0 0.5rem 0;
}
.message-content-header p {
margin: 0;
color: var(--dark-grey);
}
.message-body {
background-color: #ffffff;
padding: 1rem;
border-radius: 8px;
white-space: pre-wrap;
word-wrap: break-word;
height: 300px;
overflow-y: auto;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: #ffffff;
padding: 2rem;
border-radius: var(--border-radius);
width: 90%;
max-width: 500px;
box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
position: relative;
}
.modal-content h3 {
margin-top: 0;
color: var(--primary-purple);
}
.modal-content ol {
padding-left: 1.5rem;
line-height: 1.8;
font-size: 1.05rem;
color: var(--dark-grey);
}
.modal-content code {
background-color: var(--light-purple);
color: var(--text-light);
padding: 0.3em 0.6em;
border-radius: 6px;
font-family: monospace;
font-weight: 500;
}
.close-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 1.75rem;
cursor: pointer;
color: var(--dark-grey);
}
/* Responsive */
@media (max-width: 768px) {
body {
-webkit-text-size-adjust: 100%; /* Prevent iOS font scaling */
}
#app-main {
padding: 0 1rem;
}
.app-header {
padding: 1rem; /* Adjust padding for mobile */
/* Keep flex row layout for alignment */
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-links {
margin-left: 0; /* Reset margin for mobile */
}
.nav-links a {
margin: 0 0 0 1rem; /* Space out the links */
}
.hero-section {
padding: 2rem 1.5rem;
margin-bottom: 1.5rem;
text-align: center; /* Ensure content is centered on mobile */
}
.hero-section h1 {
font-size: 1.8rem;
}
.hero-section p {
font-size: 1rem;
}
.input-group {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.email-input {
max-width: none; /* Remove max-width on mobile */
}
.inbox-section {
min-width: 0; /* Reset min-width */
padding: 1rem;
}
.inbox-container {
flex-direction: column;
min-height: auto;
gap: 1rem;
}
.message-list,
.message-detail {
width: 100%;
border-right: none;
padding-right: 0;
}
.message-list {
border-bottom: 1px solid var(--medium-grey);
padding-bottom: 1rem;
}
.message-detail .empty-state {
padding: 2rem 0;
}
.message-body {
height: auto; /* Adjust height for mobile */
max-height: 400px;
}
.modal-content {
width: 95%;
padding: 1.5rem;
}
}

View File

@@ -1,4 +1,5 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import './assets/main.css' // 引入新的全局样式文件
createApp(App).mount('#app') createApp(App).mount('#app')

View File

@@ -1,26 +0,0 @@
SECRET_KEY=d4f7a1b3c2e987f05a6d3b4c8e1f2097
DOMAIN=shenjianl.cn
HOSTNAMES=mail.shenjianl.cn
POSTMASTER=admin
DB_FLAVOR=mysql
DB_HOST=43.143.145.172
DB_USER=root
DB_PASSWORD=kyff145972
DB_NAME=maildb
WEB_ADMIN=/mailu
WEB_WEBMAIL=/mailu/webmail
ENABLE_UNPRIVILEGED_USER=false
PASSWORD_SCHEME=BLF-CRYPT
ENABLE_ADMIN=true
ENABLE_WEBMAIL=true
ENABLE_WEBDAV=false
ENABLE_ANTIVIRUS=false
ENABLE_ANTISPAM=true
DEFAULT_QUOTA=2G
ADMIN_PASSWORD=admin
TLS_FLAVOR=notls
MESSAGE_SIZE_LIMIT=20000000