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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user