up
This commit is contained in:
@@ -1,37 +1,8 @@
|
|||||||
# 2026-06-01 MailGo 项目开发完成
|
# 2026-06-01
|
||||||
|
|
||||||
## 完成内容
|
## MailGo v3.1 增量更新
|
||||||
- 完整Go邮件系统 MailGo 开发,通过2轮QA验证
|
- **域名表单 TLS 端口自动切换**:domain_form.html 增加 JS `togglePorts()`,勾选 TLS 自动切换 SMTP 25→465、IMAP 143→993、POP3 110→995
|
||||||
- 核心组件: SMTP(go-smtp) + IMAP(go-imap v1) + POP3(手写TCP) + Web(Gin)
|
- **管理后台邮件可点击查看**:admin/mails.html 邮件行改为可点击跳转 `/admin/mails/:id`
|
||||||
- Web功能: 登录认证、收件箱、草稿箱、发件箱、撰写邮件(含附件)、设置(修改密码)、管理后台(域名/用户/DNS提示)
|
- **新增 AdminViewMail handler + admin/mail_view.html 模板**:管理员可查看任意用户邮件详情
|
||||||
- 模板架构从base继承模式经3轮迭代重构为自包含+子模板模式
|
- **新增 AdminDownloadAttachment handler + 路由**:管理员可下载任意用户附件(绕过归属校验)
|
||||||
- 默认管理员: admin@example.com / admin
|
- **AdminHandler 新增 storage 依赖**:`NewAdminHandler(stores, attStorage)` 签名变更
|
||||||
- Windows测试路径: ./win/etc/mail_go + ./win/srv/mail_go
|
|
||||||
|
|
||||||
## QA结果
|
|
||||||
- Round 1: 12/15 通过,3项失败(/drafts和/settings路由缺失,/admin路径误测)
|
|
||||||
- Round 2: 12/12 全部通过,修复后回归验证完成
|
|
||||||
|
|
||||||
## 关键修复
|
|
||||||
- SMTP ListenAndServeTLS 签名问题 → 创建独立TLS Server实例
|
|
||||||
- IMAP v2 beta API不稳定 → 切换v1
|
|
||||||
- 模板 {{template .VarName .}} 不支持 → 重构为自包含模式
|
|
||||||
- 补充 /drafts、/settings 路由及密码修改功能
|
|
||||||
|
|
||||||
## 增强需求实现(v2)
|
|
||||||
- DKIM私钥自动生成: 域名创建时自动生成RSA 2048密钥对,DNS提示页显示DKIM TXT记录
|
|
||||||
- 域名编辑: /admin/domains/:id/edit,可修改端口/TLS/重新生成DKIM
|
|
||||||
- 附件配额限制: 上传时检查用户QuotaBytes vs UsedBytes,SMTP收信也更新配额
|
|
||||||
- 富文本编辑器: compose页集成Quill.js(CDN),支持HTML邮件(multipart/alternative)
|
|
||||||
- OAuth2/LDAP认证: 默认关闭,配置文件控制开关;internal/auth/包(provider+ldap+oauth2)
|
|
||||||
- 新增依赖: github.com/go-ldap/ldap/v3, golang.org/x/oauth2
|
|
||||||
- go build + go vet 通过
|
|
||||||
|
|
||||||
## 增强需求实现(v3)
|
|
||||||
- Banlist系统: BanEntry模型+BanStore+BanMiddleware,登录失败N次ban IP,admin /admin/bans 查看/解ban
|
|
||||||
- 管理仪表盘增强: 邮件分布(INBOX/Sent/Drafts/Trash计数+大小)、今日/7日收发统计、ban计数
|
|
||||||
- 全量邮件查看: /admin/mails 支持文件夹筛选+分页,MailStore新增ListAll/ListAllByFolder
|
|
||||||
- Config新增: Ban BanConfig (max_fail_attempts默认5, ban_duration_min默认30分钟)
|
|
||||||
- 新建5文件: ban_store.go, ban.go中间件, banned.html, admin/bans.html, admin/mails.html
|
|
||||||
- 修改13文件: models.go, db.go, config.go, stores.go, mail_store.go, auth.go, admin.go, server.go, main.go + 5个admin模板
|
|
||||||
- go build + go vet 通过
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
"mail_go/internal/dkim"
|
"mail_go/internal/dkim"
|
||||||
|
"mail_go/internal/storage"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -18,11 +19,12 @@ import (
|
|||||||
// AdminHandler handles admin-related routes (dashboard, domain/user management).
|
// AdminHandler handles admin-related routes (dashboard, domain/user management).
|
||||||
type AdminHandler struct {
|
type AdminHandler struct {
|
||||||
stores *store.Stores
|
stores *store.Stores
|
||||||
|
storage *storage.AttachmentStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminHandler creates a new AdminHandler with the given stores.
|
// NewAdminHandler creates a new AdminHandler with the given stores and attachment storage.
|
||||||
func NewAdminHandler(stores *store.Stores) *AdminHandler {
|
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage) *AdminHandler {
|
||||||
return &AdminHandler{stores: stores}
|
return &AdminHandler{stores: stores, storage: attStorage}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dashboard renders the admin dashboard with summary statistics.
|
// Dashboard renders the admin dashboard with summary statistics.
|
||||||
@@ -630,6 +632,59 @@ func (h *AdminHandler) ListMails(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminViewMail renders the detail view of a specific mail for admin.
|
||||||
|
func (h *AdminHandler) AdminViewMail(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "无效的邮件ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "邮件不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载附件
|
||||||
|
attachments, _ := h.stores.Attachments.ListByMessage(uint(id))
|
||||||
|
|
||||||
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
|
||||||
|
c.HTML(200, "admin_mail_view", gin.H{
|
||||||
|
"currentUser": currentUser,
|
||||||
|
"message": msg,
|
||||||
|
"attachments": attachments,
|
||||||
|
"activeFolder": "mails",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDownloadAttachment serves an attachment file for admin (bypasses user ownership check).
|
||||||
|
func (h *AdminHandler) AdminDownloadAttachment(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "无效的附件ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
att, err := h.stores.Attachments.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "附件不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.storage.Read(att.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "读取附件失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", att.FileName))
|
||||||
|
c.Data(http.StatusOK, att.ContentType, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formIntOrDefault extracts an integer from a form field, returning the default if missing/invalid.
|
||||||
|
|
||||||
// formIntOrDefault extracts an integer from a form field, returning the default if missing/invalid.
|
// formIntOrDefault extracts an integer from a form field, returning the default if missing/invalid.
|
||||||
func formIntOrDefault(c *gin.Context, key string, defaultVal int) int {
|
func formIntOrDefault(c *gin.Context, key string, defaultVal int) int {
|
||||||
val := c.PostForm(key)
|
val := c.PostForm(key)
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
|||||||
func (ws *WebServer) registerRoutes() {
|
func (ws *WebServer) registerRoutes() {
|
||||||
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg)
|
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg)
|
||||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage)
|
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage)
|
||||||
adminHandler := handlers.NewAdminHandler(ws.stores)
|
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage)
|
||||||
|
|
||||||
// Apply BanMiddleware globally before public routes
|
// Apply BanMiddleware globally before public routes
|
||||||
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
||||||
@@ -170,6 +170,8 @@ func (ws *WebServer) registerRoutes() {
|
|||||||
admin.GET("/users/:id/edit", adminHandler.EditUser)
|
admin.GET("/users/:id/edit", adminHandler.EditUser)
|
||||||
admin.POST("/users/:id", adminHandler.UpdateUser)
|
admin.POST("/users/:id", adminHandler.UpdateUser)
|
||||||
admin.GET("/mails", adminHandler.ListMails)
|
admin.GET("/mails", adminHandler.ListMails)
|
||||||
|
admin.GET("/mails/:id", adminHandler.AdminViewMail)
|
||||||
|
admin.GET("/attachment/:id", adminHandler.AdminDownloadAttachment)
|
||||||
admin.GET("/bans", adminHandler.ListBans)
|
admin.GET("/bans", adminHandler.ListBans)
|
||||||
admin.POST("/bans/:id/unban", adminHandler.UnbanIP)
|
admin.POST("/bans/:id/unban", adminHandler.UnbanIP)
|
||||||
admin.POST("/bans/cleanup", adminHandler.CleanupBans)
|
admin.POST("/bans/cleanup", adminHandler.CleanupBans)
|
||||||
|
|||||||
@@ -46,10 +46,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="tls_enabled" {{if .domain.TlsEnabled}}checked{{end}}>
|
<input type="checkbox" name="tls_enabled" id="tls_enabled" {{if .domain.TlsEnabled}}checked{{end}} onchange="togglePorts()">
|
||||||
启用 TLS
|
启用 TLS
|
||||||
</label>
|
</label>
|
||||||
|
<p style="color:#7f8c8d;font-size:12px;margin-top:4px;">勾选后端口将自动切换为 SSL/TLS 标准端口</p>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
function togglePorts() {
|
||||||
|
var tls = document.getElementById('tls_enabled').checked;
|
||||||
|
document.querySelector('input[name="smtp_port"]').value = tls ? 465 : 25;
|
||||||
|
document.querySelector('input[name="imap_port"]').value = tls ? 993 : 143;
|
||||||
|
document.querySelector('input[name="pop3_port"]').value = tls ? 995 : 110;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{{if .isEdit}}
|
{{if .isEdit}}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{{define "admin_mail_view"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>查看邮件 - MailGo 管理后台</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox">返回邮箱</a>
|
||||||
|
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||||
|
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||||
|
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||||
|
<a href="/admin/mails" class="active">所有邮件</a>
|
||||||
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<a href="/admin/mails" class="btn" style="background:#bdc3c7;color:#fff;">返回邮件列表</a>
|
||||||
|
</div>
|
||||||
|
<h2>{{if .message.Subject}}{{.message.Subject}}{{else}}(无主题){{end}}</h2>
|
||||||
|
<div class="mail-meta" style="margin-top:12px;">
|
||||||
|
<p><strong>发件人:</strong> {{.message.FromAddr}}</p>
|
||||||
|
<p><strong>收件人:</strong> {{.message.ToAddr}}</p>
|
||||||
|
{{if .message.CcAddr}}<p><strong>抄送:</strong> {{.message.CcAddr}}</p>{{end}}
|
||||||
|
<p><strong>所属用户:</strong> {{if .message.User.ID}}{{.message.User.Username}}{{else}}—{{end}}</p>
|
||||||
|
<p><strong>文件夹:</strong> {{.message.Folder}}</p>
|
||||||
|
<p><strong>时间:</strong> {{.message.Date.Format "2006-01-02 15:04:05"}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mail-body">
|
||||||
|
{{if .message.HtmlBody}}
|
||||||
|
{{.message.HtmlBody | safeHTML}}
|
||||||
|
{{else}}
|
||||||
|
<pre style="white-space:pre-wrap;font-family:inherit;">{{.message.TextBody}}</pre>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .attachments}}
|
||||||
|
<div class="attachment-list">
|
||||||
|
<h4 style="margin-bottom:8px;">附件</h4>
|
||||||
|
{{range .attachments}}
|
||||||
|
<div class="attachment-item">
|
||||||
|
📎 <a href="/admin/attachment/{{.ID}}">{{.FileName}}</a>
|
||||||
|
<span style="color:#7f8c8d;font-size:12px;">({{formatBytes .FileSize}})</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .messages}}
|
{{range .messages}}
|
||||||
<tr>
|
<tr style="cursor:pointer;" onclick="location.href='/admin/mails/{{.ID}}'">
|
||||||
<td>{{.FromAddr}}</td>
|
<td>{{.FromAddr}}</td>
|
||||||
<td>{{.ToAddr}}</td>
|
<td>{{.ToAddr}}</td>
|
||||||
<td>{{.Subject}}</td>
|
<td>{{.Subject}}</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user