diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..da6aea0 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index b16440a..6c7ac50 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index bb381f3..e9c721c 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 ` 请求头 - **数据格式**: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 ` 请求头 + --- ## 🤝 贡献指南 diff --git a/docker-compose.yml b/docker-compose.yml index 27ef900..0e514a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go index 57bb4b0..acb3591 100644 --- a/internal/handlers/admin.go +++ b/internal/handlers/admin.go @@ -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, }) } diff --git a/internal/handlers/email.go b/internal/handlers/email.go index 8ff8681..adfc8de 100644 --- a/internal/handlers/email.go +++ b/internal/handlers/email.go @@ -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编码 diff --git a/internal/router/router.go b/internal/router/router.go index 3adddf0..17d2992 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) diff --git a/internal/utils/email_parser.go b/internal/utils/email_parser.go index 8c64e7c..d5a4f0e 100644 --- a/internal/utils/email_parser.go +++ b/internal/utils/email_parser.go @@ -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 } \ No newline at end of file diff --git a/web/frontend/src/components/EmailDetailDialog.vue b/web/frontend/src/components/EmailDetailDialog.vue index a353778..0b40177 100644 --- a/web/frontend/src/components/EmailDetailDialog.vue +++ b/web/frontend/src/components/EmailDetailDialog.vue @@ -65,9 +65,15 @@
- +
- +

此邮件没有HTML内容

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