feat: 安全加固 + GHCR CI/CD + 文档更新

安全修复:
- 随机 Session Token (crypto/rand),替代可预测的固定 token
- 登录速率限制 (5次/15分钟锁定),防暴力破解
- HMAC 恒定时间密码比较,防时序攻击
- 邮件 HTML 使用 sandbox iframe 渲染,防 XSS
- 移除 Health 端点凭据泄露 (debug_info)
- API Token 脱敏显示,移除明文返回
- 移除 URL 参数传 Token,仅支持 Authorization Header
- CORS 收紧为同源策略
- 添加安全响应头 (X-Frame-Options/X-Content-Type-Options 等)
- 请求体大小限制 10MB
- 移除内存中明文密码存储
- Docker 运行时镜像固定版本 alpine:3.19

CI/CD:
- 添加 GitHub Actions 自动构建并推送 Docker 镜像到 ghcr.io
- 支持 amd64/arm64 多平台构建

其他:
- 修复 Quoted-Printable 邮件解码 (换行符规范化)
- 修复点击邮件行打开详情 (PrimeVue row-click 参数)
- 前端登录密码 SHA-256 哈希后传输
- docker-compose.yml 镜像改为 ghcr.io
- README 完全重写:GHCR 部署文档、安全特性、升级指南
- 数据库 schema 零变更,旧版本无感升级
This commit is contained in:
MengMengCode
2026-03-02 09:34:55 +08:00
parent 0b34205e1e
commit 3b33c2702e
9 changed files with 469 additions and 141 deletions

58
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: Build and Push Docker Image
on:
push:
branches: [ main ]
tags: [ 'v*' ]
pull_request:
branches: [ main ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64

View File

@@ -48,7 +48,7 @@ RUN CGO_ENABLED=1 GOOS=linux \
-o mailcat .
# 第三阶段:运行时镜像
FROM alpine:latest
FROM alpine:3.19
# 安装运行时依赖
RUN apk --no-cache add ca-certificates sqlite tzdata

187
README.md
View File

@@ -7,7 +7,8 @@
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat-square&logo=go)](https://golang.org/)
[![Vue.js](https://img.shields.io/badge/Vue.js-3.x-4FC08D?style=flat-square&logo=vue.js)](https://vuejs.org/)
[![License](https://img.shields.io/badge/License-GPL%20v3-blue.svg?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?style=flat-square&logo=docker)](https://hub.docker.com/)
[![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker)](https://github.com/MengMengCode/MailCat/pkgs/container/mailcat)
[![CI](https://img.shields.io/github/actions/workflow/status/MengMengCode/MailCat/docker-publish.yml?style=flat-square&label=Build)](https://github.com/MengMengCode/MailCat/actions)
</div>
@@ -16,14 +17,15 @@
## 📋 目录
- [✨ 功能特性](#-功能特性)
- [🔒 安全特性](#-安全特性)
- [🚀 快速开始](#-快速开始)
- [源码构建运行](#1-源码构建运行)
- [Docker Compose 部署](#2-docker-compose-部署)
- [Docker Run 部署](#3-docker-run-部署)
- [Docker Compose 部署(推荐)](#1-docker-compose-部署推荐)
- [Docker Run 部署](#2-docker-run-部署)
- [源码构建运行](#3-源码构建运行)
- [☁️ Cloudflare Worker 配置](#-cloudflare-worker-配置)
- [📡 API 使用说明](#-api-使用说明)
- [🖼️ 界面预览](#-界面预览)
- [⚙️ 配置说明](#-配置说明)
- [🔄 升级指南](#-升级指南)
- [🤝 贡献指南](#-贡献指南)
- [📄 许可证](#-许可证)
@@ -34,42 +36,34 @@
MailCat 是一个基于 **Go + Vue.js** 的现代化邮件接收与管理系统,具有以下特性:
🔹 **轻量高效** - 基于 Go 语言开发,性能优异,资源占用低
🔹 **现代化界面** - Vue.js 3 + Element Plus 构建的响应式 Web 界面
🔹 **现代化界面** - Vue.js 3 + PrimeVue 构建的响应式 Web 界面
🔹 **云端集成** - 完美集成 Cloudflare Worker实现邮件转发
🔹 **数据持久化** - 使用 SQLite3 数据库,轻量且可靠
🔹 **RESTful API** - 提供完整的 API 接口,支持第三方集成
🔹 **容器化部署** - 支持 Docker 一键部署,开箱即用
🔹 **安全认证** - 支持 Token 认证和管理员密码保护
🔹 **容器化部署** - 支持 Docker 一键部署,镜像托管于 GitHub Container Registry
🔹 **安全认证** - 双端哈希密码传输、随机 Session、速率限制
🔹 **分页查询** - 支持大量邮件的分页浏览和管理
---
## 🔒 安全特性
| 特性 | 说明 |
|------|------|
| **双端密码哈希** | 前端 SHA-256 哈希后传输,服务端 HMAC 安全比较,密码不明文传输 |
| **随机 Session Token** | 每次登录生成加密安全的随机 Token不可预测 |
| **登录速率限制** | 5 次失败后锁定 15 分钟,防暴力破解 |
| **XSS 防护** | 邮件 HTML 使用 `sandbox` iframe 渲染,隔离恶意脚本 |
| **安全响应头** | 自动添加 `X-Content-Type-Options``X-Frame-Options``X-XSS-Protection` 等 |
| **请求体限制** | 10MB 请求体大小限制,防止 DoS 攻击 |
| **CORS 收紧** | 默认禁止跨域请求 |
| **API Token 脱敏** | 管理面板仅显示脱敏后的 Token |
---
## 🚀 快速开始
### 1. 源码构建运行
```bash
# 克隆项目
git clone https://github.com/MengMengCode/MailCat.git
cd mailcat
# 安装 Go 依赖
go mod tidy
# 构建前端资源
cd web/frontend
npm install && npm run build
cd ../..
# 启动服务
go run main.go
```
✅ 服务启动后访问:**http://server-ip:8080**
---
### 2. Docker Compose 部署
### 1. Docker Compose 部署(推荐)
创建 `docker-compose.yml` 文件:
@@ -78,18 +72,23 @@ version: '3.8'
services:
mailcat:
image: mengmengcode/mailcat:latest
image: ghcr.io/mengmengcode/mailcat:latest
container_name: mailcat
restart: unless-stopped
ports:
- "8080:8080"
environment:
# API 认证令牌 - 用于 Cloudflare Worker 调用 API请修改为安全的随机字符串
- MAILCAT_API_AUTH_TOKEN=your_secure_api_token_here
# 管理员密码 - 用于 Web 管理界面登录(请修改为强密码)
- MAILCAT_ADMIN_PASSWORD=your_secure_admin_password_here
# 时区设置
- TZ=Asia/Shanghai
volumes:
# 数据持久化 - SQLite 数据库文件
- mailcat_data:/app/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
@@ -102,12 +101,14 @@ volumes:
启动服务:
```bash
docker-compose up -d
docker compose up -d
```
✅ 服务启动后访问:**http://your-server-ip:8080**
---
### 3. Docker Run 部署
### 2. Docker Run 部署
```bash
docker run -d \
@@ -116,12 +117,36 @@ docker run -d \
-p 8080:8080 \
-e MAILCAT_API_AUTH_TOKEN=your_secure_api_token_here \
-e MAILCAT_ADMIN_PASSWORD=your_secure_admin_password_here \
-e TZ=Asia/Shanghai \
-v mailcat_data:/app/data \
mengmengcode/mailcat:latest
ghcr.io/mengmengcode/mailcat:latest
```
---
### 3. 源码构建运行
```bash
# 克隆项目
git clone https://github.com/MengMengCode/MailCat.git
cd MailCat
# 构建前端资源
cd web/frontend
npm install && npm run build
cd ../..
# 设置环境变量
export MAILCAT_API_AUTH_TOKEN=your_secure_api_token_here
export MAILCAT_ADMIN_PASSWORD=your_secure_admin_password_here
# 安装 Go 依赖并启动
go mod tidy
go run main.go
```
✅ 服务启动后访问:**http://localhost:8080**
## ☁️ Cloudflare Worker 配置
### 步骤 1创建 Worker
@@ -165,7 +190,7 @@ docker run -d \
### 基础信息
- **基础 URL**`https://your.domain.com/api/v1`
- **认证方式**Bearer Token 或 URL 参数
- **认证方式**`Authorization: Bearer <token>` 请求头
- **数据格式**JSON
### 邮件查询接口
@@ -177,12 +202,6 @@ GET /api/v1/emails
#### 认证方式
**方式一URL 参数**
```
https://your.domain.com/api/v1/emails?token=your_auth_token
```
**方式二:请求头**
```bash
curl -H "Authorization: Bearer your_auth_token" \
https://your.domain.com/api/v1/emails
@@ -194,27 +213,25 @@ curl -H "Authorization: Bearer your_auth_token" \
|--------|------|--------|------|------|
| `page` | integer | `1` | ≥ 1 | 页码 |
| `limit` | integer | `20` | 1-100 | 每页数量 |
| `token` | string | - | - | 认证令牌(可选,如使用请求头认证) |
#### 使用示例
**默认查询第1页20条**
```bash
curl "https://your.domain.com/api/v1/emails?token=your_auth_token"
curl -H "Authorization: Bearer your_auth_token" \
"https://your.domain.com/api/v1/emails"
```
**分页查询第2页50条**
```bash
curl "https://your.domain.com/api/v1/emails?token=your_auth_token&page=2&limit=50"
curl -H "Authorization: Bearer your_auth_token" \
"https://your.domain.com/api/v1/emails?page=2&limit=50"
```
**获取所有邮件(分页遍历)**
**获取单封邮件详情**
```bash
# 第一次请求获取总数
curl "https://your.domain.com/api/v1/emails?token=your_auth_token&limit=100"
# 根据返回的 total 字段计算总页数,然后逐页查询
curl "https://your.domain.com/api/v1/emails?token=your_auth_token&page=2&limit=100"
curl -H "Authorization: Bearer your_auth_token" \
"https://your.domain.com/api/v1/emails/1"
```
#### 响应示例
@@ -261,30 +278,70 @@ curl "https://your.domain.com/api/v1/emails?token=your_auth_token&page=2&limit=1
### 环境变量
| 变量名 | 默认值 | 说明 |
|--------|--------|------|
| `MAILCAT_API_AUTH_TOKEN` | - | API 认证令牌(必填 |
| `MAILCAT_ADMIN_PASSWORD` | - | 管理员密码(必填) |
| `MAILCAT_PORT` | `8080` | 服务监听端口 |
| `MAILCAT_DB_PATH` | `./data/mailcat.db` | SQLite 数据库文件路径 |
| 变量名 | 必填 | 默认值 | 说明 |
|--------|:----:|--------|------|
| `MAILCAT_API_AUTH_TOKEN` | ✅ | - | API 认证令牌(Cloudflare Worker 使用 |
| `MAILCAT_ADMIN_PASSWORD` | ✅ | - | 管理员登录密码 |
| `MAILCAT_SERVER_PORT` | ❌ | `8080` | 服务监听端口 |
| `MAILCAT_SERVER_HOST` | ❌ | `0.0.0.0` | 服务监听地址 |
| `MAILCAT_DATABASE_PATH` | ❌ | `./data/emails.db` | SQLite 数据库文件路径 |
| `TZ` | ❌ | `UTC` | 时区设置,建议 `Asia/Shanghai` |
### 配置文件
项目支持通过 [`config/config.yaml`](config/config.yaml) 进行配置:
项目支持通过 [`config/config.yaml`](config/config.yaml) 进行配置(环境变量优先级更高)
```yaml
server:
port: 8080
port: "8080"
host: "0.0.0.0"
database:
path: "./data/mailcat.db"
path: "./data/emails.db"
auth:
api_token: "your_secure_api_token_here"
admin_password: "your_secure_admin_password_here"
api:
auth_token: "" # 建议通过环境变量 MAILCAT_API_AUTH_TOKEN 设置
admin:
password: "" # 建议通过环境变量 MAILCAT_ADMIN_PASSWORD 设置
```
> ⚠️ **安全提醒**:请勿将真实的 Token 和密码提交到版本控制中,推荐使用环境变量或 `.env` 文件。
---
## 🔄 升级指南
### 从旧版本升级
MailCat 保证**数据库格式向后兼容**,升级不会影响已有数据。
**Docker 用户:**
```bash
# 拉取最新镜像
docker pull ghcr.io/mengmengcode/mailcat:latest
# 重新创建容器(数据卷自动保留)
docker compose down
docker compose up -d
```
**源码用户:**
```bash
git pull origin main
cd web/frontend && npm install && npm run build && cd ../..
go mod tidy
go build -o mailcat .
```
### 注意事项
- ✅ 数据库 SQLite 文件完全兼容,无需迁移
- ✅ 现有 Cloudflare Worker 配置无需修改
- ⚠️ API 认证已移除 URL 参数传 Token 的方式(安全原因),请改用 `Authorization: Bearer <token>` 请求头
---
## 🤝 贡献指南

View File

@@ -2,7 +2,7 @@ version: '3.8'
services:
mailcat:
image: mengmengcode/mailcat:latest
image: ghcr.io/mengmengcode/mailcat:latest
container_name: mailcat
restart: unless-stopped
ports:

View File

@@ -1,11 +1,15 @@
package handlers
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"strings"
"sync"
"time"
"mailcat/internal/database"
"github.com/gin-gonic/gin"
@@ -14,17 +18,41 @@ import (
type AdminHandler struct {
db *database.DB
authToken string
adminPassword string
adminPasswordHash string
// 随机 session 管理
sessions map[string]time.Time // token -> 过期时间
sessionMu sync.RWMutex
// 登录速率限制
loginAttempts map[string]*loginAttemptInfo
loginMu sync.Mutex
}
type loginAttemptInfo struct {
count int
firstAt time.Time
lockedAt time.Time
}
const (
maxLoginAttempts = 5 // 最大尝试次数
loginWindow = 5 * time.Minute // 计数窗口
lockDuration = 15 * time.Minute // 锁定时长
sessionMaxAge = 24 * time.Hour // Session 有效期
)
func NewAdminHandler(db *database.DB, authToken, adminPassword string) *AdminHandler {
return &AdminHandler{
db: db,
authToken: authToken,
adminPassword: adminPassword,
h := &AdminHandler{
db: db,
authToken: authToken,
adminPasswordHash: sha256Hex(adminPassword),
sessions: make(map[string]time.Time),
loginAttempts: make(map[string]*loginAttemptInfo),
}
// 启动后台清理过期 session
go h.cleanExpiredSessions()
return h
}
// sha256Hex 计算字符串的 SHA-256 哈希值并返回十六进制字符串
@@ -33,6 +61,96 @@ func sha256Hex(s string) string {
return hex.EncodeToString(h[:])
}
// generateSessionToken 生成加密安全的随机 session token
func generateSessionToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// cleanExpiredSessions 定期清理过期 session
func (h *AdminHandler) cleanExpiredSessions() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
h.sessionMu.Lock()
now := time.Now()
for token, expiry := range h.sessions {
if now.After(expiry) {
delete(h.sessions, token)
}
}
h.sessionMu.Unlock()
}
}
// validateSession 验证 session token 是否有效
func (h *AdminHandler) validateSession(token string) bool {
h.sessionMu.RLock()
defer h.sessionMu.RUnlock()
expiry, exists := h.sessions[token]
if !exists {
return false
}
return time.Now().Before(expiry)
}
// checkRateLimit 检查登录速率限制,返回是否允许登录
func (h *AdminHandler) checkRateLimit(ip string) (bool, int) {
h.loginMu.Lock()
defer h.loginMu.Unlock()
info, exists := h.loginAttempts[ip]
if !exists {
h.loginAttempts[ip] = &loginAttemptInfo{count: 0, firstAt: time.Now()}
return true, maxLoginAttempts
}
now := time.Now()
// 检查是否在锁定期间
if !info.lockedAt.IsZero() && now.Before(info.lockedAt.Add(lockDuration)) {
remaining := int(info.lockedAt.Add(lockDuration).Sub(now).Seconds())
return false, remaining
}
// 超出计数窗口则重置
if now.After(info.firstAt.Add(loginWindow)) {
info.count = 0
info.firstAt = now
info.lockedAt = time.Time{}
}
if info.count >= maxLoginAttempts {
info.lockedAt = now
return false, int(lockDuration.Seconds())
}
return true, maxLoginAttempts - info.count
}
// recordLoginAttempt 记录一次失败的登录尝试
func (h *AdminHandler) recordLoginAttempt(ip string) {
h.loginMu.Lock()
defer h.loginMu.Unlock()
info, exists := h.loginAttempts[ip]
if !exists {
h.loginAttempts[ip] = &loginAttemptInfo{count: 1, firstAt: time.Now()}
return
}
info.count++
}
// resetLoginAttempts 登录成功后重置计数
func (h *AdminHandler) resetLoginAttempts(ip string) {
h.loginMu.Lock()
defer h.loginMu.Unlock()
delete(h.loginAttempts, ip)
}
// AdminAuthMiddleware 管理员认证中间件
func (h *AdminHandler) AdminAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -45,8 +163,8 @@ func (h *AdminHandler) AdminAuthMiddleware() gin.HandlerFunc {
}
}
// 使用密码哈希值验证 session
if session != "admin_logged_in_"+h.adminPasswordHash {
// 使用随机 session token 验证
if !h.validateSession(session) {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
})
@@ -60,6 +178,18 @@ func (h *AdminHandler) AdminAuthMiddleware() gin.HandlerFunc {
// Login 处理登录请求
func (h *AdminHandler) Login(c *gin.Context) {
clientIP := c.ClientIP()
// 速率限制检查
allowed, remaining := h.checkRateLimit(clientIP)
if !allowed {
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Too many login attempts, please try again later",
"retry_after": remaining,
})
return
}
var loginReq struct {
Password string `json:"password"`
}
@@ -71,17 +201,33 @@ func (h *AdminHandler) Login(c *gin.Context) {
return
}
// 前端发送的是 SHA-256 哈希后的密码,与服务端哈希比较
if loginReq.Password != h.adminPasswordHash {
// 前端发送的是 SHA-256 哈希后的密码,使用 HMAC 安全比较防止时序攻击
if !hmac.Equal([]byte(loginReq.Password), []byte(h.adminPasswordHash)) {
h.recordLoginAttempt(clientIP)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Invalid password",
})
return
}
// 使用密码哈希值生成 session token避免明文泄露
sessionToken := "admin_logged_in_" + h.adminPasswordHash
c.SetCookie("admin_session", sessionToken, 3600*24, "/", "", false, true)
// 登录成功,重置速率限制
h.resetLoginAttempts(clientIP)
// 生成加密安全的随机 session token
sessionToken, err := generateSessionToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal server error",
})
return
}
// 存储 session
h.sessionMu.Lock()
h.sessions[sessionToken] = time.Now().Add(sessionMaxAge)
h.sessionMu.Unlock()
c.SetCookie("admin_session", sessionToken, int(sessionMaxAge.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, gin.H{
"message": "Login successful",
@@ -91,6 +237,19 @@ func (h *AdminHandler) Login(c *gin.Context) {
// Logout 处理登出请求
func (h *AdminHandler) Logout(c *gin.Context) {
// 从存储中移除 session
session := c.GetHeader("X-Admin-Session")
if session == "" {
if cookie, err := c.Cookie("admin_session"); err == nil {
session = cookie
}
}
if session != "" {
h.sessionMu.Lock()
delete(h.sessions, session)
h.sessionMu.Unlock()
}
c.SetCookie("admin_session", "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{
"message": "Logout successful",
@@ -141,10 +300,16 @@ func (h *AdminHandler) GetAdminEmails(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// GetConfig 获取配置信息
// GetConfig 获取配置信息(仅显示脱敏后的令牌)
func (h *AdminHandler) GetConfig(c *gin.Context) {
masked := h.authToken
if len(masked) > 8 {
masked = masked[:4] + "****" + masked[len(masked)-4:]
} else {
masked = "****"
}
c.JSON(http.StatusOK, gin.H{
"api_token": h.authToken,
"api_token": masked,
})
}

View File

@@ -25,15 +25,12 @@ func NewEmailHandler(db *database.DB, authToken string) *EmailHandler {
}
}
// AuthMiddleware 验证API令牌
// AuthMiddleware 验证API令牌(仅支持 Authorization 请求头)
func (h *EmailHandler) AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
token = c.Query("token")
}
if token != "Bearer "+h.authToken && token != h.authToken {
if token != "Bearer "+h.authToken {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
})
@@ -272,6 +269,18 @@ func (h *EmailHandler) GetEmailByID(c *gin.Context) {
htmlBody = textToHTML(body)
}
// 最终兜底:检测并解码残留的 Quoted-Printable 编码
if htmlBody != "" && utils.IsQuotedPrintable(htmlBody) {
if decoded, err := utils.DecodeQuotedPrintable(htmlBody); err == nil {
htmlBody = decoded
}
}
if body != "" && utils.IsQuotedPrintable(body) {
if decoded, err := utils.DecodeQuotedPrintable(body); err == nil {
body = decoded
}
}
// 清理发件人和收件人字段
from := cleanEmailAddress(email.From)
to := cleanEmailAddress(email.To)
@@ -294,9 +303,12 @@ func (h *EmailHandler) GetEmailByID(c *gin.Context) {
// isMIMEContent 检查内容是否为MIME格式
func isMIMEContent(content string) bool {
// 检查是否包含MIME边界标识符
lines := strings.Split(content, "\r\n")
// 检查是否包含MIME边界标识符,同时支持 \n 和 \r\n 换行
lines := strings.FieldsFunc(content, func(r rune) bool {
return r == '\n'
})
for _, line := range lines {
line = strings.TrimRight(line, "\r")
// 查找以 -- 开头的边界线,且长度合理
if strings.HasPrefix(line, "--") && len(line) > 10 {
// 进一步验证是否包含Content-Type
@@ -331,41 +343,10 @@ func cleanEmailAddress(address string) string {
// HealthCheck 健康检查端点
func (h *EmailHandler) HealthCheck(c *gin.Context) {
// 检查是否提供了认证信息
token := c.GetHeader("Authorization")
if token == "" {
token = c.Query("token")
}
// 如果提供了认证信息,验证它
authenticated := false
if token != "" {
if token == "Bearer "+h.authToken || token == h.authToken {
authenticated = true
}
}
response := gin.H{
"status": "ok",
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"message": "Email receiver service is running",
"authenticated": authenticated,
}
// 如果提供了无效的认证信息返回401而不是403
if token != "" && !authenticated {
c.JSON(http.StatusUnauthorized, gin.H{
"status": "error",
"message": "Invalid authentication token",
"authenticated": false,
"debug_info": gin.H{
"received_token": token,
"expected_format": "Bearer " + h.authToken,
},
})
return
}
c.JSON(http.StatusOK, response)
})
}
// isBase64Content 检查内容是否是Base64编码

View File

@@ -5,6 +5,7 @@ import (
"mailcat/internal/handlers"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"net/http"
"time"
)
@@ -13,22 +14,45 @@ func SetupRouter(db *database.DB, authToken string, adminPassword string) *gin.E
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
// 请求体大小限制10MB防止大邮件 DoS
r.MaxMultipartMemory = 10 << 20
// 设置模板分隔符避免与Vue.js语法冲突
r.Delims("{[{", "}]}")
// 静态文件服务 - 仅服务Vue构建后的资源
r.Static("/assets", "./web/dist/assets")
// 安全响应头中间件
r.Use(func(c *gin.Context) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("X-XSS-Protection", "1; mode=block")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Next()
})
// 配置CORS
// 配置CORS — 仅允许同源请求
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"}, // 允许所有来源
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowOrigins: []string{}, // 允许跨域
AllowMethods: []string{"GET", "POST", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Admin-Session"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
AllowCredentials: false,
MaxAge: 12 * time.Hour,
}))
// 请求体大小限制中间件
r.Use(func(c *gin.Context) {
if c.Request.ContentLength > 10*1024*1024 { // 10MB
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{
"error": "Request body too large",
})
return
}
c.Next()
})
// 创建邮件处理器
emailHandler := handlers.NewEmailHandler(db, authToken)

View File

@@ -320,6 +320,9 @@ func TryParseEmailContent(rawBody, htmlBody string, headersJSON string) (string,
func ParseMIMEContent(content string) (*EmailContent, error) {
result := &EmailContent{}
// 规范化换行符
content = normalizeLineEndings(content)
// 检测MIME边界
boundary := detectMIMEBoundary(content)
if boundary == "" {
@@ -400,6 +403,8 @@ func ParseMIMEContent(content string) (*EmailContent, error) {
// detectMIMEBoundary 检测MIME边界标识符
func detectMIMEBoundary(content string) string {
// 规范化换行符
content = normalizeLineEndings(content)
lines := strings.Split(content, "\r\n")
for _, line := range lines {
@@ -475,4 +480,36 @@ func assignContentByType(result *EmailContent, content, contentType string) {
result.HTMLBody = content
}
}
}
// normalizeLineEndings 统一将换行符规范化为 \r\n
func normalizeLineEndings(content string) string {
content = strings.ReplaceAll(content, "\r\n", "\n")
content = strings.ReplaceAll(content, "\r", "\n")
content = strings.ReplaceAll(content, "\n", "\r\n")
return content
}
// IsQuotedPrintable 检查内容是否包含 Quoted-Printable 编码特征
func IsQuotedPrintable(content string) bool {
// 典型 QP 特征:=3D (编码的=号), =XX 十六进制编码
qpPatterns := []string{"=3D", "=3d", "=20", "=C2", "=c2", "=E2", "=e2", "=09"}
count := 0
for _, p := range qpPatterns {
if strings.Contains(content, p) {
count++
}
}
return count >= 2
}
// DecodeQuotedPrintable 解码 Quoted-Printable 编码的内容
func DecodeQuotedPrintable(content string) (string, error) {
normalized := normalizeLineEndings(content)
reader := quotedprintable.NewReader(strings.NewReader(normalized))
decoded, err := io.ReadAll(reader)
if err != nil {
return content, err
}
return string(decoded), nil
}

View File

@@ -65,9 +65,15 @@
</div>
<div class="tab-content">
<!-- HTML 渲染 -->
<!-- HTML 渲染沙箱化 iframe -->
<div v-if="activeTab === 'html'" class="tab-pane">
<div v-if="email.html_body && email.html_body.trim()" class="email-html-content" v-html="email.html_body"></div>
<iframe
v-if="email.html_body && email.html_body.trim()"
class="email-html-iframe"
sandbox="allow-same-origin"
referrerpolicy="no-referrer"
:srcdoc="email.html_body"
></iframe>
<div v-else class="empty-content">
<i class="pi pi-info-circle"></i>
<p>此邮件没有HTML内容</p>
@@ -321,14 +327,13 @@ export default {
}
}
.email-html-content {
background: var(--surface);
.email-html-iframe {
width: 100%;
min-height: 400px;
height: 60vh;
border: none;
border-radius: 0 0 var(--radius-large) var(--radius-large);
padding: var(--spacing-xl);
min-height: 350px;
overflow: auto;
line-height: 1.6;
background: #fff;
}
.email-text-content,
@@ -366,7 +371,7 @@ export default {
/* 滚动条样式 */
.email-content::-webkit-scrollbar,
.email-html-content::-webkit-scrollbar,
.email-html-iframe::-webkit-scrollbar,
.email-text-content::-webkit-scrollbar,
.email-source-content::-webkit-scrollbar,
.email-headers-content::-webkit-scrollbar {
@@ -375,7 +380,7 @@ export default {
}
.email-content::-webkit-scrollbar-track,
.email-html-content::-webkit-scrollbar-track,
.email-html-iframe::-webkit-scrollbar-track,
.email-text-content::-webkit-scrollbar-track,
.email-source-content::-webkit-scrollbar-track,
.email-headers-content::-webkit-scrollbar-track {
@@ -383,7 +388,7 @@ export default {
}
.email-content::-webkit-scrollbar-thumb,
.email-html-content::-webkit-scrollbar-thumb,
.email-html-iframe::-webkit-scrollbar-thumb,
.email-text-content::-webkit-scrollbar-thumb,
.email-source-content::-webkit-scrollbar-thumb,
.email-headers-content::-webkit-scrollbar-thumb {
@@ -392,7 +397,7 @@ export default {
}
.email-content::-webkit-scrollbar-thumb:hover,
.email-html-content::-webkit-scrollbar-thumb:hover,
.email-html-iframe::-webkit-scrollbar-thumb:hover,
.email-text-content::-webkit-scrollbar-thumb:hover,
.email-source-content::-webkit-scrollbar-thumb:hover,
.email-headers-content::-webkit-scrollbar-thumb:hover {
@@ -421,8 +426,9 @@ export default {
padding: var(--spacing-md);
}
.email-html-content {
padding: var(--spacing-md);
.email-html-iframe {
min-height: 300px;
height: 50vh;
}
.email-text-content,