feat(imap): 多客户端状态实时同步 + 当前连接「断开并封禁」

- 推送扩展:IMAP STORE(已读/星标/\Deleted)推送 FETCH 标志更新、
  EXPUNGE 推送 ExpungeUpdate(删除前序号)、APPEND/COPY/MOVE 推送
  新邮件;POP3 QUIT 删除、Web 标已读/删除同样实时同步到 IMAP 客户端
- Pusher 接口统一 SMTP/POP3/Web 的推送入口,IMAP 内部操作经会话
  通道直接入队(非阻塞,满则丢弃)
- 当前连接页新增「断开并封禁」:connhub 支持断开回调,SMTP/POP3
  关底层连接、IMAP 经 ForEachConn 按地址断开;一键封禁 180 天并
  断开该 IP 全部在线连接,黑名单页可随时解封
- 修复:POP3 PASS 成功后保留完整邮箱(此前被裸用户名覆盖)
- 新增测试:断开/按 IP 断开、flags/expunge 推送内容、POP3 删除推送、
  Web 断开封禁处理器;全量 -race 通过
This commit is contained in:
2026-08-19 19:56:04 +08:00
parent ede85e0698
commit 6a9dcd0285
15 changed files with 745 additions and 69 deletions
+36
View File
@@ -43,6 +43,42 @@ func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage
return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, caddyDataDir: caddyDataDir, outbound: ob, protocolLogKeepDays: protocolLogKeepDays, hub: hub}
}
// manualBanDuration 管理员手动封禁时长(180 天,与自动封禁档位上限制一致)。
const manualBanDuration = 180 * 24 * time.Hour
// DisconnectConnection 强制断开指定连接并封禁其 IP(管理后台「断开并封禁」)。
// 封禁后该 IP 的所有在线连接一并断开。
func (h *AdminHandler) DisconnectConnection(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的连接ID")
return
}
conn, ok := h.hub.Get(id)
if !ok {
c.String(http.StatusNotFound, "连接不存在或已断开")
return
}
// 加入黑名单:180 天封禁(管理员可随时解封)
if err := h.stores.Bans.Create(&db.BanEntry{
IPAddress: conn.IP,
Reason: "管理员手动封禁(连接断开)",
FailCount: 0,
BanCount: 0,
ExpiresAt: time.Now().Add(manualBanDuration),
}); err != nil {
c.String(http.StatusInternalServerError, "封禁失败: %v", err)
return
}
// 断开该 IP 的全部连接(含本连接与其他协议连接)
n := h.hub.DisconnectByIP(conn.IP)
log.Printf("admin: 已封禁并断开 IP %s 的 %d 个连接", conn.IP, n)
c.Redirect(http.StatusFound, "/admin/connections")
}
// ListConnections 渲染当前协议连接页面(SMTP/IMAP/POP3 实时连接)。
func (h *AdminHandler) ListConnections(c *gin.Context) {
conns := h.hub.List()
+101
View File
@@ -0,0 +1,101 @@
package handlers
import (
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"time"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/store"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// performPost 发送 POST 请求并返回响应(用于处理器测试)。
func performPost(r *gin.Engine, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", path, nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
return rec
}
// TestDisconnectConnection 验证「断开并封禁」:创建黑名单记录并断开该 IP 全部连接。
func TestDisconnectConnection(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.BanEntry{}); err != nil {
t.Fatalf("migrate: %v", err)
}
stores := store.NewStores(gdb)
hub := connhub.New()
var closed atomic.Int32
// 目标 IP 两个连接(模拟多协议在线)
c1 := hub.Register("smtp", "203.0.113.77", 25, false)
c1.SetDisconnect(func() { closed.Add(1) })
c2 := hub.Register("imap", "203.0.113.77", 993, true)
c2.SetDisconnect(func() { closed.Add(1) })
// 其他 IP 不应受影响
c3 := hub.Register("pop3", "203.0.113.78", 110, false)
c3.SetDisconnect(func() { closed.Add(1) })
h := &AdminHandler{stores: stores, hub: hub}
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/admin/connections/:id/disconnect", h.DisconnectConnection)
rec := performPost(r, "/admin/connections/1/disconnect")
if rec.Code != 302 {
t.Fatalf("status = %d, want 302", rec.Code)
}
// 该 IP 的两个连接都被断开,其他连接不受影响
if closed.Load() != 2 {
t.Fatalf("closed = %d, want 2", closed.Load())
}
if n := hub.Counts()["pop3"]; n != 1 {
t.Fatalf("pop3 count = %d, want 1 (unaffected)", n)
}
// 黑名单记录:180 天封禁
banned, entry := stores.Bans.IsBanned("203.0.113.77")
if !banned {
t.Fatal("IP should be banned")
}
if entry.Reason != "管理员手动封禁(连接断开)" {
t.Fatalf("reason = %q", entry.Reason)
}
wantExpiry := time.Now().Add(180 * 24 * time.Hour)
if entry.ExpiresAt.Before(wantExpiry.Add(-time.Minute)) || entry.ExpiresAt.After(wantExpiry.Add(time.Minute)) {
t.Fatalf("expiry = %v, want ~180 days", entry.ExpiresAt)
}
}
// TestDisconnectConnectionNotFound 验证不存在的连接返回 404。
func TestDisconnectConnectionNotFound(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.BanEntry{}); err != nil {
t.Fatalf("migrate: %v", err)
}
h := &AdminHandler{stores: store.NewStores(gdb), hub: connhub.New()}
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/admin/connections/:id/disconnect", h.DisconnectConnection)
rec := performPost(r, "/admin/connections/999/disconnect")
if rec.Code != 404 {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
+41 -7
View File
@@ -12,8 +12,8 @@ import (
"time"
"mail_go/internal/db"
"mail_go/internal/imap_server"
"mail_go/internal/outbound"
"mail_go/internal/smtp_server"
"mail_go/internal/storage"
"mail_go/internal/store"
@@ -50,14 +50,14 @@ type MailHandler struct {
stores *store.Stores
storage *storage.AttachmentStorage
outbound *outbound.Manager
// notify 本地投递成功通知(IMAP 新邮件推送),可空
notify smtp_server.NewMailNotify
// pusher 邮件状态变化推送(IMAP 客户端实时同步),可空
pusher imap_server.Pusher
}
// NewMailHandler creates a new MailHandler with the given stores, attachment
// storage and outbound delivery manager.
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, notify smtp_server.NewMailNotify) *MailHandler {
return &MailHandler{stores: stores, storage: attStorage, outbound: ob, notify: notify}
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, pusher imap_server.Pusher) *MailHandler {
return &MailHandler{stores: stores, storage: attStorage, outbound: ob, pusher: pusher}
}
// folderCounts returns sidebar badge counts for the current user.
@@ -386,8 +386,8 @@ func (h *MailHandler) DoSend(c *gin.Context) {
return
}
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
if h.notify != nil {
h.notify(rcptUser.Username+"@"+rcptUser.Domain.Name, inboxMsg)
if h.pusher != nil {
h.pusher.PushNewMessage(rcptUser.Username+"@"+rcptUser.Domain.Name, inboxMsg)
}
}
@@ -638,8 +638,30 @@ func (h *MailHandler) Delete(c *gin.Context) {
_ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize)
}
_ = h.stores.Attachments.DeleteByMessage(uint(id))
// 删除前计算消息在所属文件夹中的序号(用于 Expunge 推送)
var seq uint32
if msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, msg.Folder); err == nil {
for i := range msgs {
if msgs[i].ID == uint(id) {
seq = uint32(i + 1)
break
}
}
}
_ = h.stores.Mails.Delete(uint(id))
// 删除 → 推送给该用户的其他 IMAP 客户端
if h.pusher != nil && seq > 0 {
userEmail := ""
if cu, ok := c.Get("currentUser"); ok {
if u, ok := cu.(*db.User); ok {
userEmail = u.Username + "@" + u.Domain.Name
}
}
h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq})
}
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
referer := safeRedirectPath(c.GetHeader("Referer"))
if referer == "" {
@@ -665,6 +687,18 @@ func (h *MailHandler) MarkRead(c *gin.Context) {
_ = h.stores.Mails.MarkRead(uint(id))
// 已读变化 → 推送给该用户的其他 IMAP 客户端
if h.pusher != nil {
msg.IsRead = true
userEmail := ""
if cu, ok := c.Get("currentUser"); ok {
if u, ok := cu.(*db.User); ok {
userEmail = u.Username + "@" + u.Domain.Name
}
}
h.pusher.PushFlagsChanged(userEmail, msg.Folder, msg)
}
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
referer := safeRedirectPath(c.GetHeader("Referer"))
if referer == "" {
+7 -6
View File
@@ -15,9 +15,9 @@ import (
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/imap_server"
"mail_go/internal/mailutil"
"mail_go/internal/outbound"
"mail_go/internal/smtp_server"
"mail_go/internal/storage"
"mail_go/internal/store"
"mail_go/internal/web/handlers"
@@ -54,8 +54,8 @@ type WebServer struct {
caddyDataDir string
outbound *outbound.Manager
hub *connhub.Hub
// notify 本地投递成功通知(IMAP 新邮件推送),可空
notify smtp_server.NewMailNotify
// pusher 邮件状态变化推送(IMAP 客户端实时同步),可空
pusher imap_server.Pusher
}
// templateFuncs returns custom template functions for rendering.
@@ -179,7 +179,7 @@ func avatarStyle(s string) string {
// NewWebServer creates a new WebServer, initializes the Gin engine,
// configures sessions, middleware, and registers all routes.
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig, caddyCfg config.CaddyConfig, ob *outbound.Manager, hub *connhub.Hub, notify smtp_server.NewMailNotify) (*WebServer, error) {
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig, caddyCfg config.CaddyConfig, ob *outbound.Manager, hub *connhub.Hub, pusher imap_server.Pusher) (*WebServer, error) {
if err := config.ValidateSecretKey(cfg.SecretKey); err != nil {
return nil, err
}
@@ -226,7 +226,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
caddyDataDir: caddyCfg.DataDir,
outbound: ob,
hub: hub,
notify: notify,
pusher: pusher,
}
ws.registerRoutes()
@@ -236,7 +236,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
// registerRoutes sets up all HTTP routes with their handlers and middleware.
func (ws *WebServer) registerRoutes() {
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg)
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound, ws.notify)
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound, ws.pusher)
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound, ws.cfg.ProtocolLogKeepDays, ws.hub)
// Apply BanMiddleware globally before public routes
@@ -308,6 +308,7 @@ func (ws *WebServer) registerRoutes() {
admin.GET("/protocol-logs", adminHandler.ListProtocolLogs)
admin.POST("/protocol-logs/cleanup", adminHandler.CleanupProtocolLogs)
admin.GET("/connections", adminHandler.ListConnections)
admin.POST("/connections/:id/disconnect", adminHandler.DisconnectConnection)
}
}
@@ -61,6 +61,7 @@
<th>连接时间</th>
<th>时长</th>
<th>最后活跃</th>
<th>操作</th>
</tr>
</thead>
<tbody>
@@ -79,9 +80,15 @@
<td>{{.Connected.Format "2006-01-02 15:04:05"}}</td>
<td>{{durationSeconds ($.now.Sub .Connected)}}s</td>
<td>{{.LastActive.Format "2006-01-02 15:04:05"}}</td>
<td>
<form method="POST" action="/admin/connections/{{.ID}}/disconnect" style="display:inline;"
onsubmit="return confirm('确定要断开 IP {{.IP}} 的所有连接并加入黑名单(180 天)吗?');">
<button type="submit" class="btn btn-sm btn-danger">断开并封禁</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="9" style="text-align:center;color:#7f8c8d;">当前没有活动连接</td></tr>
<tr><td colspan="10" style="text-align:center;color:#7f8c8d;">当前没有活动连接</td></tr>
{{end}}
</tbody>
</table>