From ede85e06984814cd1c1cdde525c0f8a5ed186450 Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 19 Aug 2026 19:40:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(imap):=20=E6=96=B0=E9=82=AE=E4=BB=B6?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E6=8E=A8=E9=80=81=EF=BC=88IDLE=EF=BC=89+=20?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=BD=93=E5=89=8D=E8=BF=9E=E6=8E=A5=E7=9B=91?= =?UTF-8?q?=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IMAP 推送:imapBackend 实现 backend.BackendUpdater,SMTP 本地投递 与 Web 写信投递成功后 NotifyNewMessage,挂起 IDLE 的客户端即时收到 新邮件 FETCH 通知(按用户名+INBOX 过滤广播,通道满非阻塞丢弃) - 当前连接:新增 internal/connhub 连接注册中心,SMTP/IMAP/POP3 三协议 注册/注销/用户名/TLS/活跃时间追踪;后台新增「当前连接」页 (/admin/connections,统计卡片+连接表格,每 5 秒自动刷新) - 新增测试:connhub 并发安全、推送内容/非阻塞/nil 安全、 后台页面渲染;全量 -race 通过 --- README.md | 3 + internal/connhub/hub.go | 138 ++++++++++++++++ internal/connhub/hub_test.go | 115 ++++++++++++++ internal/imap_server/backend.go | 63 +++++++- internal/imap_server/notify_test.go | 148 ++++++++++++++++++ internal/imap_server/server.go | 51 +++++- internal/pop3_server/server.go | 18 ++- internal/smtp_server/server.go | 48 +++++- internal/smtp_server/server_test.go | 2 +- internal/web/handlers/admin.go | 31 +++- internal/web/handlers/mail.go | 11 +- internal/web/render_test.go | 18 +++ internal/web/server.go | 28 ++-- internal/web/session_secret_test.go | 5 +- internal/web/templates/admin/bans.html | 3 +- internal/web/templates/admin/connections.html | 94 +++++++++++ internal/web/templates/admin/dashboard.html | 1 + internal/web/templates/admin/dns_hint.html | 1 + internal/web/templates/admin/domain_form.html | 1 + internal/web/templates/admin/domains.html | 1 + internal/web/templates/admin/mail_view.html | 1 + internal/web/templates/admin/mails.html | 1 + internal/web/templates/admin/outbound.html | 1 + .../web/templates/admin/protocol_logs.html | 3 +- internal/web/templates/admin/user_form.html | 1 + internal/web/templates/admin/users.html | 1 + main.go | 48 +++--- 27 files changed, 783 insertions(+), 53 deletions(-) create mode 100644 internal/connhub/hub.go create mode 100644 internal/connhub/hub_test.go create mode 100644 internal/imap_server/notify_test.go create mode 100644 internal/web/templates/admin/connections.html diff --git a/README.md b/README.md index f1fbb6f..0a90132 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Web 前端采用 QQ 邮箱风格的布局:顶部导航 + 左侧文件夹栏 + - **Web 邮箱**:QQ 邮箱风格界面,支持收件箱 / 已发送 / 草稿箱、未读角标与搜索过滤、全选 / 批量删除、发件人头像、富文本编辑(Quill.js)、附件上传/下载 - **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、外发队列管理、IP 封禁管理、仪表盘统计 - **协议调用日志**:SMTP / IMAP / POP3 每次连接自动记录来源 IP、用户名、成功/失败、失败原因与操作摘要,可按协议/状态/IP/用户名/时间筛选,用于分析密码爆破、中继滥用等攻击行为(默认保留 30 天,自动清理) +- **IMAP 新邮件推送**:本地投递(SMTP/Web 写信)成功后实时推送,挂起 IDLE 的客户端即时收到新邮件通知(无需轮询) +- **当前连接监控**:管理后台实时查看 SMTP/IMAP/POP3 活动连接(来源 IP、用户名、TLS、时长),每 5 秒自动刷新 - **外部认证**:OAuth2(Google / GitHub)、LDAP(可选,默认关闭) - **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP(**阶段性封禁**:前 3 次达到失败阈值只计数,第 4 次起封禁并按档位递增 30分钟 → 3小时 → 3个月 → 半年,成功登录清零)、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封 - **多数据库**:默认 SQLite,可切换 MySQL @@ -404,6 +406,7 @@ mailgo/ │ │ ├── server.go # IMAP 服务 │ │ └── backend.go # IMAP 后端 │ ├── pop3_server/server.go # POP3 服务 +│ ├── connhub/hub.go # 协议连接注册中心(当前连接监控) │ ├── storage/attachment.go # 附件文件存储 │ ├── dkim/keys.go # DKIM 密钥生成 │ ├── auth/ diff --git a/internal/connhub/hub.go b/internal/connhub/hub.go new file mode 100644 index 0000000..affe599 --- /dev/null +++ b/internal/connhub/hub.go @@ -0,0 +1,138 @@ +// Package connhub 提供邮件协议(SMTP/IMAP/POP3)当前活动连接的注册中心, +// 供管理后台实时查看连接情况(来源 IP、用户、TLS、时长等)。 +package connhub + +import ( + "sort" + "sync" + "time" +) + +// Conn 表示一个活动中的协议连接。字段由 Hub 的锁保护。 +type Conn struct { + ID uint64 // 自增序号 + Protocol string // smtp | imap | pop3 + IP string + Port int + User string // 认证后填充 + TLS bool + Connected time.Time + LastActive time.Time + + hub *Hub +} + +// Hub 管理所有活动连接(同一把锁保护注册表与连接字段)。 +type Hub struct { + mu sync.Mutex + seq uint64 + conns map[uint64]*Conn +} + +// New 创建连接注册中心。 +func New() *Hub { + return &Hub{conns: make(map[uint64]*Conn)} +} + +// Register 注册一个新连接并返回其句柄;调用方在连接结束时调用 Close()。 +func (h *Hub) Register(protocol, ip string, port int, tls bool) *Conn { + if h == nil { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + h.seq++ + now := time.Now() + c := &Conn{ + ID: h.seq, + Protocol: protocol, + IP: ip, + Port: port, + TLS: tls, + Connected: now, + LastActive: now, + hub: h, + } + h.conns[c.ID] = c + return c +} + +// SetUser 记录认证成功的用户名(邮箱)并刷新最后活跃时间。 +func (c *Conn) SetUser(u string) { + if c == nil || c.hub == nil { + return + } + c.hub.mu.Lock() + c.User = u + c.LastActive = time.Now() + c.hub.mu.Unlock() +} + +// SetTLS 更新连接的 TLS 状态(如 POP3 STLS 升级之后)。 +func (c *Conn) SetTLS(on bool) { + if c == nil || c.hub == nil { + return + } + c.hub.mu.Lock() + c.TLS = on + c.hub.mu.Unlock() +} + +// Touch 刷新最后活跃时间。 +func (c *Conn) Touch() { + if c == nil || c.hub == nil { + return + } + c.hub.mu.Lock() + c.LastActive = time.Now() + c.hub.mu.Unlock() +} + +// Close 从注册中心移除该连接。 +func (c *Conn) Close() { + if c == nil || c.hub == nil { + return + } + c.hub.mu.Lock() + delete(c.hub.conns, c.ID) + c.hub.mu.Unlock() +} + +// List 返回当前所有活动连接(拷贝),按连接时间升序。 +func (h *Hub) List() []Conn { + if h == nil { + return nil + } + h.mu.Lock() + out := make([]Conn, 0, len(h.conns)) + for _, c := range h.conns { + out = append(out, Conn{ + ID: c.ID, + Protocol: c.Protocol, + IP: c.IP, + Port: c.Port, + User: c.User, + TLS: c.TLS, + Connected: c.Connected, + LastActive: c.LastActive, + }) + } + h.mu.Unlock() + + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// Counts 返回按协议分组的当前连接数。 +func (h *Hub) Counts() map[string]int { + if h == nil { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + counts := make(map[string]int) + for _, c := range h.conns { + counts[c.Protocol]++ + } + return counts +} diff --git a/internal/connhub/hub_test.go b/internal/connhub/hub_test.go new file mode 100644 index 0000000..ac10aa7 --- /dev/null +++ b/internal/connhub/hub_test.go @@ -0,0 +1,115 @@ +package connhub + +import ( + "sync" + "testing" + "time" +) + +func TestRegisterListCountClose(t *testing.T) { + h := New() + + c1 := h.Register("smtp", "10.0.0.1", 25, false) + c2 := h.Register("imap", "10.0.0.2", 993, true) + c3 := h.Register("pop3", "10.0.0.3", 110, false) + + if n := len(h.List()); n != 3 { + t.Fatalf("list len = %d, want 3", n) + } + counts := h.Counts() + if counts["smtp"] != 1 || counts["imap"] != 1 || counts["pop3"] != 1 { + t.Fatalf("counts = %v", counts) + } + + c2.SetUser("alice@example.com") + c1.SetTLS(true) + time.Sleep(time.Millisecond) + c3.Touch() + + // 用户名 / TLS 状态生效 + var imapUser string + for _, c := range h.List() { + if c.Protocol == "imap" { + imapUser = c.User + } + if c.Protocol == "smtp" && !c.TLS { + t.Fatal("smtp conn should be TLS after SetTLS(true)") + } + if c.Protocol == "pop3" && !c.LastActive.After(c.Connected) { + t.Fatal("pop3 conn LastActive should be after Connected after Touch") + } + } + if imapUser != "alice@example.com" { + t.Fatalf("imap user = %q", imapUser) + } + + c1.Close() + c2.Close() + if n := len(h.List()); n != 1 { + t.Fatalf("after close, len = %d, want 1", n) + } + if h.Counts()["imap"] != 0 { + t.Fatalf("imap count after close = %d, want 0", h.Counts()["imap"]) + } +} + +func TestNilHubSafe(t *testing.T) { + var h *Hub + if c := h.Register("smtp", "1.2.3.4", 25, false); c != nil { + t.Fatal("nil hub Register must return nil") + } + if h.List() != nil || h.Counts() != nil { + t.Fatal("nil hub List/Counts must be nil") + } + var c *Conn + c.SetUser("x") // 不应 panic + c.Touch() + c.SetTLS(true) + c.Close() +} + +func TestConcurrentRegisterClose(t *testing.T) { + h := New() + const n = 50 + var wg sync.WaitGroup + conns := make([]*Conn, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + conns[i] = h.Register("smtp", "10.0.0.1", 25, false) + conns[i].SetUser("u") + conns[i].Touch() + }(i) + } + wg.Wait() + + if len(h.List()) != n { + t.Fatalf("len = %d, want %d", len(h.List()), n) + } + + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + conns[i].Close() + }(i) + } + wg.Wait() + if len(h.List()) != 0 { + t.Fatalf("after concurrent close, len = %d, want 0", len(h.List())) + } +} + +func TestListOrderedByID(t *testing.T) { + h := New() + h.Register("smtp", "1.1.1.1", 25, false) + h.Register("imap", "2.2.2.2", 143, false) + h.Register("pop3", "3.3.3.3", 110, false) + list := h.List() + for i := 1; i < len(list); i++ { + if list[i].ID <= list[i-1].ID { + t.Fatalf("list not ordered by ID: %+v", list) + } + } +} diff --git a/internal/imap_server/backend.go b/internal/imap_server/backend.go index af18ba1..99b3527 100644 --- a/internal/imap_server/backend.go +++ b/internal/imap_server/backend.go @@ -11,6 +11,7 @@ import ( "time" "mail_go/config" + "mail_go/internal/connhub" "mail_go/internal/db" "mail_go/internal/mailutil" "mail_go/internal/store" @@ -24,11 +25,62 @@ import ( // ---------- imapBackend ---------- -// imapBackend implements backend.Backend. +// imapBackend implements backend.Backend and backend.BackendUpdater. type imapBackend struct { stores *store.Stores banCfg config.BanConfig port int + hub *connhub.Hub + + // updates 承载新邮件等后端更新,由 go-imap 服务器广播给相关客户端。 + updates chan backend.Update +} + +// Updates 实现 backend.BackendUpdater:新邮件推送通道(广播按用户名与 +// 邮箱过滤,只送达已选中对应邮箱的客户端)。 +func (b *imapBackend) Updates() <-chan backend.Update { + return b.updates +} + +// buildNewMessageUpdate 为一条新投递到 INBOX 的邮件构造 IMAP 更新。 +// seq 取该邮件在 INBOX 中的序号(通知时机在入库之后,取当前 INBOX 长度)。 +func buildNewMessageUpdate(stores *store.Stores, userEmail string, msg *db.Message) *backend.MessageUpdate { + if stores == nil || msg == nil || userEmail == "" { + return nil + } + seq := uint32(1) + if msgs, err := stores.Mails.ListAllByUserAndFolder(msg.UserID, "INBOX"); err == nil { + seq = uint32(len(msgs)) + } + + flags := make([]string, 0, 2) + if msg.IsRead { + flags = append(flags, "\\Seen") + } + if msg.IsFlagged { + flags = append(flags, "\\Flagged") + } + + imapMsg := imap.NewMessage(seq, []imap.FetchItem{imap.FetchUid, imap.FetchFlags, imap.FetchInternalDate, imap.FetchRFC822Size, imap.FetchEnvelope}) + imapMsg.Uid = uint32(msg.ID) + imapMsg.Flags = flags + imapMsg.InternalDate = msg.Date + imapMsg.Size = uint32(len(msg.RawData)) + imapMsg.Envelope = &imap.Envelope{ + Date: msg.Date, + Subject: msg.Subject, + From: parseAddressList(msg.FromAddr), + Sender: parseAddressList(msg.FromAddr), + ReplyTo: parseAddressList(msg.FromAddr), + To: parseAddressList(msg.ToAddr), + Cc: parseAddressList(msg.CcAddr), + MessageId: msg.MessageID, + } + + return &backend.MessageUpdate{ + Update: backend.NewUpdate(userEmail, "INBOX"), + Message: imapMsg, + } } // Login authenticates a user by email and password. @@ -58,6 +110,12 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) logID := b.recordLogin(clientIP, username, true, "", "LOGIN 成功", 0, now) + // 连接追踪:注册到当前连接中心,Logout 时注销 + conn := b.hub.Register("imap", clientIP, b.port, connInfo.TLS != nil) + if conn != nil { + conn.SetUser(email) + } + return &imapUser{ stores: b.stores, id: user.ID, @@ -65,6 +123,7 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) logID: logID, clientIP: clientIP, startedAt: now, + conn: conn, }, nil } @@ -98,6 +157,7 @@ type imapUser struct { logID uint clientIP string startedAt time.Time + conn *connhub.Conn } // Username returns the user's email address. @@ -186,6 +246,7 @@ func (u *imapUser) Logout() error { if err := u.stores.ProtocolLogs.UpdateDuration(u.logID, durationMs); err != nil { log.Printf("IMAP: 更新协议日志失败: %v", err) } + u.conn.Close() return nil } diff --git a/internal/imap_server/notify_test.go b/internal/imap_server/notify_test.go new file mode 100644 index 0000000..992f1c2 --- /dev/null +++ b/internal/imap_server/notify_test.go @@ -0,0 +1,148 @@ +package imap_server + +import ( + "path/filepath" + "testing" + "time" + + "mail_go/config" + "mail_go/internal/connhub" + "mail_go/internal/db" + "mail_go/internal/store" + + "github.com/emersion/go-imap/backend" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// TestNotifyNewMessage 验证本地投递成功后推送的 MessageUpdate 内容正确。 +func TestNotifyNewMessage(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.User{}, &db.Domain{}, &db.Message{}); err != nil { + t.Fatalf("migrate: %v", err) + } + stores := store.NewStores(gdb) + + domain := &db.Domain{Name: "example.com"} + if err := stores.Domains.Create(domain); err != nil { + t.Fatalf("create domain: %v", err) + } + user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true} + if err := stores.Users.Create(user); err != nil { + t.Fatalf("create user: %v", err) + } + email := "alice@example.com" + + // 已有一封旧邮件,新邮件应为 INBOX 第 2 封 + old := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "old", Date: time.Now()} + if err := stores.Mails.Create(old); err != nil { + t.Fatalf("create old message: %v", err) + } + inboxMsg := &db.Message{ + UserID: user.ID, + Folder: "INBOX", + FromAddr: "sender@other.com", + ToAddr: email, + Subject: "新邮件", + RawData: "From: sender@other.com\r\nSubject: 新邮件\r\n\r\nhello", + MessageID: "", + Date: time.Now(), + IsRead: false, + } + if err := stores.Mails.Create(inboxMsg); err != nil { + t.Fatalf("create message: %v", err) + } + + hub := connhub.New() + srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub) + // 模拟明文 + TLS 两个监听器(生产环境由 Start/StartTLS 注册) + srv.newServer("127.0.0.1:143", nil) + srv.newServer("127.0.0.1:993", nil) + srv.NotifyNewMessage(email, inboxMsg) + + // 两个监听器(明文/TLS)各有一个 backend 通道,都应收到同一更新 + srv.beMu.Lock() + bes := append([]*imapBackend(nil), srv.bes...) + srv.beMu.Unlock() + if len(bes) == 0 { + t.Fatal("no backends registered") + } + + for i, b := range bes { + select { + case upd := <-b.updates: + mu, ok := upd.(*backend.MessageUpdate) + if !ok { + t.Fatalf("backend %d: update type = %T, want *MessageUpdate", i, upd) + } + if mu.Username() != email { + t.Fatalf("backend %d: username = %q, want %q", i, mu.Username(), email) + } + if mu.Mailbox() != "INBOX" { + t.Fatalf("backend %d: mailbox = %q, want INBOX", i, mu.Mailbox()) + } + if mu.Message.Uid != uint32(inboxMsg.ID) { + t.Fatalf("backend %d: uid = %d, want %d", i, mu.Message.Uid, inboxMsg.ID) + } + if mu.Message.SeqNum != 2 { + t.Fatalf("backend %d: seq = %d, want 2", i, mu.Message.SeqNum) + } + if mu.Message.Envelope == nil || mu.Message.Envelope.Subject != "新邮件" { + t.Fatalf("backend %d: envelope missing subject", i) + } + case <-time.After(time.Second): + t.Fatalf("backend %d: no update received", i) + } + } +} + +// TestNotifyNewMessageChannelFull 验证通道满时推送不阻塞(非阻塞丢弃)。 +func TestNotifyNewMessageChannelFull(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.User{}, &db.Domain{}, &db.Message{}); err != nil { + t.Fatalf("migrate: %v", err) + } + stores := store.NewStores(gdb) + + hub := connhub.New() + srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub) + srv.newServer("127.0.0.1:143", nil) + srv.newServer("127.0.0.1:993", nil) + + msg := &db.Message{ID: 1, Folder: "INBOX", Date: time.Now()} + done := make(chan struct{}) + go func() { + // 灌满所有 backend 通道(容量 256),再调用必须立即返回 + srv.beMu.Lock() + bes := append([]*imapBackend(nil), srv.bes...) + srv.beMu.Unlock() + for _, b := range bes { + for i := 0; i < cap(b.updates); i++ { + b.updates <- backend.NewUpdate("a@b", "INBOX") + } + } + srv.NotifyNewMessage("a@b", msg) + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("NotifyNewMessage blocked on full channel") + } +} + +// TestNotifyNewMessageNilSafe 验证空参数/空指针安全。 +func TestNotifyNewMessageNilSafe(t *testing.T) { + var srv *IMAPServer + srv.NotifyNewMessage("a@b", &db.Message{ID: 1}) // 不应 panic + srv = NewIMAPServer(config.IMAPConfig{}, nil, nil, config.BanConfig{}, nil) + srv.NotifyNewMessage("", &db.Message{ID: 1}) // 空邮箱 + srv.NotifyNewMessage("a@b", nil) // 空消息 +} diff --git a/internal/imap_server/server.go b/internal/imap_server/server.go index 20c65b1..2e60eee 100644 --- a/internal/imap_server/server.go +++ b/internal/imap_server/server.go @@ -6,8 +6,11 @@ import ( "log" "net" "strconv" + "sync" "mail_go/config" + "mail_go/internal/connhub" + "mail_go/internal/db" "mail_go/internal/store" "mail_go/internal/tlsutil" @@ -21,19 +24,56 @@ type IMAPServer struct { cfg config.IMAPConfig banCfg config.BanConfig tlsLoader *tlsutil.Loader + hub *connhub.Hub + + beMu sync.Mutex + bes []*imapBackend // 各监听器(明文/TLS)的 backend,用于新邮件推送 } // NewIMAPServer creates a new IMAP server instance. tlsLoader may be nil // when TLS is not configured. -func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig) *IMAPServer { +func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub) *IMAPServer { return &IMAPServer{ stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, + hub: hub, } } +// NotifyNewMessage 向所有 IMAP 监听器推送新邮件通知(go-imap 广播时按 +// 用户名+邮箱过滤,只送达已选中 INBOX 的客户端,IDLE 挂起时实时收到 +// FETCH 响应)。由 SMTP/Web 本地投递成功时调用;channel 满时非阻塞丢弃。 +func (s *IMAPServer) NotifyNewMessage(userEmail string, msg *db.Message) { + if s == nil || userEmail == "" || msg == nil { + return + } + update := buildNewMessageUpdate(s.stores, userEmail, msg) + if update == nil { + return + } + + s.beMu.Lock() + bes := append([]*imapBackend(nil), s.bes...) + s.beMu.Unlock() + + for _, b := range bes { + select { + case b.updates <- update: + default: + log.Printf("IMAP: 新邮件推送通道已满,丢弃 %s 的更新 (msg=%d)", userEmail, msg.ID) + } + } +} + +// registerBackend 记录新建的 backend(用于新邮件推送)。 +func (s *IMAPServer) registerBackend(be *imapBackend) { + s.beMu.Lock() + s.bes = append(s.bes, be) + s.beMu.Unlock() +} + func (s *IMAPServer) tlsConfig() (*tls.Config, error) { if s.tlsLoader == nil { return nil, fmt.Errorf("IMAP TLS certificate or key not configured") @@ -44,7 +84,14 @@ func (s *IMAPServer) tlsConfig() (*tls.Config, error) { // newServer creates a configured imapserver.Server with the given address. func (s *IMAPServer) newServer(addr string, tlsConfig *tls.Config) *imapserver.Server { - be := &imapBackend{stores: s.stores, banCfg: s.banCfg, port: portOf(addr)} + be := &imapBackend{ + stores: s.stores, + banCfg: s.banCfg, + port: portOf(addr), + hub: s.hub, + updates: make(chan backend.Update, 256), + } + s.registerBackend(be) srv := imapserver.New(be) srv.Addr = addr srv.TLSConfig = tlsConfig diff --git a/internal/pop3_server/server.go b/internal/pop3_server/server.go index e5a66ae..4b18d91 100644 --- a/internal/pop3_server/server.go +++ b/internal/pop3_server/server.go @@ -12,6 +12,7 @@ import ( "time" "mail_go/config" + "mail_go/internal/connhub" "mail_go/internal/db" "mail_go/internal/store" "mail_go/internal/tlsutil" @@ -24,13 +25,14 @@ type POP3Server struct { cfg config.POP3Config banCfg config.BanConfig tlsLoader *tlsutil.Loader + hub *connhub.Hub wg sync.WaitGroup } // NewPOP3Server creates a new POP3 server instance. tlsLoader may be nil // when TLS is not configured. -func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig) *POP3Server { - return &POP3Server{stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader} +func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub) *POP3Server { + return &POP3Server{stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub} } func (s *POP3Server) tlsConfig() (*tls.Config, error) { @@ -133,6 +135,9 @@ func (s *POP3Server) handleConn(conn net.Conn, port int) { return } + // 连接追踪:注册到当前连接中心,连接结束时注销 + activeConn := s.hub.Register("pop3", clientIP, port, false) + // 会话状态(供协议日志汇总) var ( authUser *db.User @@ -147,6 +152,12 @@ func (s *POP3Server) handleConn(conn net.Conn, port int) { var deleted map[int]bool tlsActive := false + defer func() { + if activeConn != nil { + activeConn.Close() + } + }() + sendResponse(conn, "+OK MailGo POP3 server ready") for { @@ -163,6 +174,7 @@ func (s *POP3Server) handleConn(conn net.Conn, port int) { parts := strings.SplitN(line, " ", 2) cmd := strings.ToUpper(parts[0]) commandCount[cmd]++ + activeConn.Touch() arg := "" if len(parts) > 1 { arg = strings.TrimSpace(parts[1]) @@ -186,6 +198,7 @@ func (s *POP3Server) handleConn(conn net.Conn, port int) { } } else { authFailReason = "" + activeConn.SetUser(authUsername) } case "STAT": s.handleSTAT(conn, messages, deleted) @@ -233,6 +246,7 @@ func (s *POP3Server) handleConn(conn net.Conn, port int) { conn = tlsConn reader = bufio.NewReader(conn) tlsActive = true + activeConn.SetTLS(true) case "TOP": s.handleTOP(conn, arg, messages, deleted) case "UIDL": diff --git a/internal/smtp_server/server.go b/internal/smtp_server/server.go index 2a20926..dbfa4df 100644 --- a/internal/smtp_server/server.go +++ b/internal/smtp_server/server.go @@ -12,6 +12,7 @@ import ( "time" "mail_go/config" + "mail_go/internal/connhub" "mail_go/internal/db" "mail_go/internal/mailutil" "mail_go/internal/outbound" @@ -32,6 +33,10 @@ const ( smtpModeImplicitTLS ) +// NewMailNotify 是本地新邮件投递完成后的通知回调(IMAP 推送用), +// userEmail 为收件人完整邮箱,msg 为已入库的邮件。 +type NewMailNotify func(userEmail string, msg *db.Message) + // SMTPServer wraps go-smtp servers and provides local mail delivery. type SMTPServer struct { stores *store.Stores @@ -40,12 +45,14 @@ type SMTPServer struct { cfg config.SMTPConfig banCfg config.BanConfig tlsLoader *tlsutil.Loader + hub *connhub.Hub + notify NewMailNotify // 本地投递成功通知(IMAP 新邮件推送),可空 } // NewSMTPServer creates a new SMTP server instance. tlsLoader may be nil // when TLS is not configured. -func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, tlsLoader *tlsutil.Loader, banCfg config.BanConfig) *SMTPServer { - return &SMTPServer{stores: stores, storage: attStorage, outbound: ob, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader} +func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub, notify NewMailNotify) *SMTPServer { + return &SMTPServer{stores: stores, storage: attStorage, outbound: ob, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub, notify: notify} } func (s *SMTPServer) tlsConfig() (*tls.Config, error) { @@ -110,16 +117,25 @@ type smtpBackend struct { // NewSession creates a new SMTP session for the incoming connection. func (be *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) { + clientIP := store.ClientIPFromAddr(c.Conn().RemoteAddr()) + conn := be.server.hub.Register("smtp", clientIP, be.server.sessionPort(be.mode), be.server.tlsActive(c)) return &smtpSession{ backend: be, mode: be.mode, rcpts: make([]string, 0), - clientIP: store.ClientIPFromAddr(c.Conn().RemoteAddr()), + clientIP: clientIP, startedAt: time.Now(), port: be.server.sessionPort(be.mode), + conn: conn, }, nil } +// tlsActive 判断当前连接是否处于 TLS 加密状态(implicit TLS 或 STARTTLS)。 +func (s *SMTPServer) tlsActive(c *smtp.Conn) bool { + _, ok := c.TLSConnectionState() + return ok +} + // sessionPort 返回该会话监听的端口号(区分明文/TLS/提交端口),解析失败返回 0。 func (s *SMTPServer) sessionPort(mode smtpMode) int { addr := s.cfg.Addr @@ -163,6 +179,9 @@ type smtpSession struct { failReason string // 首个失败原因 msgCount int // 成功处理的邮件数(本地投递 + 外发队列) detailParts []string + + // 连接追踪 + conn *connhub.Conn } // AuthMechanisms returns supported SMTP AUTH mechanisms. @@ -191,6 +210,7 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) { return sasl.NewPlainServer(func(identity, username, password string) error { s.authTried = true s.authUsername = username + s.conn.Touch() // 已封禁 IP 一律拒绝认证(防协议层暴力破解) if banned, _ := s.backend.server.stores.Bans.IsBanned(s.clientIP); banned { s.recordFail("IP已被封禁") @@ -227,6 +247,9 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) { s.userID = user.ID s.user = user s.email = user.Username + "@" + domainName + if s.conn != nil { + s.conn.SetUser(s.email) + } return nil }), nil } @@ -293,6 +316,7 @@ func (s *smtpSession) localUserByEmail(email string) (*db.User, error) { // External recipients (authenticated sessions only) are queued for // outbound delivery. func (s *smtpSession) Data(r io.Reader) error { + s.conn.Touch() if len(s.rcpts) == 0 { s.recordFail("未指定收件人") return fmt.Errorf("no accepted recipients") @@ -318,12 +342,17 @@ func (s *smtpSession) Data(r io.Reader) error { log.Printf("SMTP: recipient not found %s, skipping", rcpt) continue } - if err := s.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil { + msg, err := s.saveMessage(user.ID, "INBOX", parsed, data, false) + if err != nil { log.Printf("SMTP: failed to create message for %s: %v", rcpt, err) continue } log.Printf("SMTP: message delivered to %s", rcpt) localDelivered++ + // 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知) + if notify := s.backend.server.notify; notify != nil && msg != nil { + notify(user.Username+"@"+user.Domain.Name, msg) + } } s.msgCount += localDelivered @@ -352,7 +381,7 @@ func (s *smtpSession) Data(r io.Reader) error { s.msgCount += externalQueued if s.authenticated && s.userID != 0 && s.mode != smtpModeInbound { - if err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil { + if _, err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil { log.Printf("SMTP: failed to save sent copy for %s: %v", s.email, err) } } @@ -453,7 +482,7 @@ func parseSMTPMessage(data []byte) (*parsedSMTPMessage, error) { return msg, nil } -func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTPMessage, data []byte, read bool) error { +func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTPMessage, data []byte, read bool) (*db.Message, error) { msg := &db.Message{ UserID: userID, MessageID: parsed.messageID, @@ -470,7 +499,7 @@ func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTP Date: parsed.date, } if err := s.backend.server.stores.Mails.Create(msg); err != nil { - return err + return nil, err } // Persist attachments to disk and link them to the message so that the @@ -494,7 +523,7 @@ func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTP } _ = s.backend.server.stores.Users.UpdateUsedBytes(userID, rec.FileSize) } - return nil + return msg, nil } // Reset clears the session state for the next message on the same connection. @@ -508,6 +537,9 @@ func (s *smtpSession) Reset() { // Logout is called when the SMTP connection is closed. func (s *smtpSession) Logout() error { s.writeProtocolLog() + if s.conn != nil { + s.conn.Close() + } return nil } diff --git a/internal/smtp_server/server_test.go b/internal/smtp_server/server_test.go index 440e303..8e4e5db 100644 --- a/internal/smtp_server/server_test.go +++ b/internal/smtp_server/server_test.go @@ -86,7 +86,7 @@ func TestSaveMessagePersistsAttachments(t *testing.T) { t.Fatalf("create user: %v", err) } - if err := sess.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil { + if _, err := sess.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil { t.Fatalf("saveMessage: %v", err) } diff --git a/internal/web/handlers/admin.go b/internal/web/handlers/admin.go index b54729f..c946c38 100644 --- a/internal/web/handlers/admin.go +++ b/internal/web/handlers/admin.go @@ -13,6 +13,7 @@ import ( "time" "mail_go/internal/caddycert" + "mail_go/internal/connhub" "mail_go/internal/db" "mail_go/internal/dkim" "mail_go/internal/outbound" @@ -32,12 +33,38 @@ type AdminHandler struct { outbound *outbound.Manager // protocolLogKeepDays SMTP/IMAP/POP3 协议日志保留天数(配置文件 [web]) protocolLogKeepDays int + // hub 当前协议连接注册中心(「当前连接」页) + hub *connhub.Hub } // NewAdminHandler creates a new AdminHandler with the given stores, attachment // storage, TLS directory, Caddy data directory and outbound delivery manager. -func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, caddyDataDir string, ob *outbound.Manager, protocolLogKeepDays int) *AdminHandler { - return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, caddyDataDir: caddyDataDir, outbound: ob, protocolLogKeepDays: protocolLogKeepDays} +func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, caddyDataDir string, ob *outbound.Manager, protocolLogKeepDays int, hub *connhub.Hub) *AdminHandler { + return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, caddyDataDir: caddyDataDir, outbound: ob, protocolLogKeepDays: protocolLogKeepDays, hub: hub} +} + +// ListConnections 渲染当前协议连接页面(SMTP/IMAP/POP3 实时连接)。 +func (h *AdminHandler) ListConnections(c *gin.Context) { + conns := h.hub.List() + counts := h.hub.Counts() + + total := len(conns) + smtpCount := counts["smtp"] + imapCount := counts["imap"] + pop3Count := counts["pop3"] + + currentUser, _ := c.Get("currentUser") + + c.HTML(200, "admin_connections", gin.H{ + "currentUser": currentUser, + "conns": conns, + "total": total, + "smtpCount": smtpCount, + "imapCount": imapCount, + "pop3Count": pop3Count, + "now": time.Now(), + "activeFolder": "connections", + }) } // Dashboard renders the admin dashboard with summary statistics. diff --git a/internal/web/handlers/mail.go b/internal/web/handlers/mail.go index 92437c3..a9ede96 100644 --- a/internal/web/handlers/mail.go +++ b/internal/web/handlers/mail.go @@ -13,6 +13,7 @@ import ( "mail_go/internal/db" "mail_go/internal/outbound" + "mail_go/internal/smtp_server" "mail_go/internal/storage" "mail_go/internal/store" @@ -49,12 +50,14 @@ type MailHandler struct { stores *store.Stores storage *storage.AttachmentStorage outbound *outbound.Manager + // notify 本地投递成功通知(IMAP 新邮件推送),可空 + notify smtp_server.NewMailNotify } // 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) *MailHandler { - return &MailHandler{stores: stores, storage: attStorage, outbound: ob} +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} } // folderCounts returns sidebar badge counts for the current user. @@ -382,6 +385,10 @@ func (h *MailHandler) DoSend(c *gin.Context) { }) return } + // 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知) + if h.notify != nil { + h.notify(rcptUser.Username+"@"+rcptUser.Domain.Name, inboxMsg) + } } // Save to Sent folder diff --git a/internal/web/render_test.go b/internal/web/render_test.go index 915fa6a..e80867d 100644 --- a/internal/web/render_test.go +++ b/internal/web/render_test.go @@ -101,6 +101,24 @@ func TestRenderAllPages(t *testing.T) { }, "keepDays": 30, }}, + {"admin_connections", ginH{ + "currentUser": user, "activeFolder": "connections", + "conns": []struct { + ID uint64 + Protocol string + IP string + Port int + User string + TLS bool + Connected time.Time + LastActive time.Time + }{ + {ID: 1, Protocol: "smtp", IP: "203.0.113.7", Port: 25, TLS: true, Connected: now.Add(-2 * time.Minute), LastActive: now}, + {ID: 2, Protocol: "imap", IP: "203.0.113.9", Port: 993, User: "admin", TLS: true, Connected: now.Add(-30 * time.Minute), LastActive: now.Add(-10 * time.Second)}, + {ID: 3, Protocol: "pop3", IP: "10.0.0.2", Port: 110, User: "alice", TLS: false, Connected: now.Add(-time.Minute), LastActive: now.Add(-30 * time.Second)}, + }, + "total": 3, "smtpCount": 1, "imapCount": 1, "pop3Count": 1, "now": now, + }}, } outDir := os.Getenv("MAILGO_PREVIEW_DIR") diff --git a/internal/web/server.go b/internal/web/server.go index 4fc3fee..3839beb 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -14,8 +14,10 @@ import ( "unicode/utf8" "mail_go/config" + "mail_go/internal/connhub" "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" @@ -51,17 +53,22 @@ type WebServer struct { banCfg config.BanConfig caddyDataDir string outbound *outbound.Manager + hub *connhub.Hub + // notify 本地投递成功通知(IMAP 新邮件推送),可空 + notify smtp_server.NewMailNotify } // templateFuncs returns custom template functions for rendering. func templateFuncs() template.FuncMap { return template.FuncMap{ - "add": func(a, b int) int { return a + b }, - "sub": func(a, b int) int { return a - b }, - "mul": func(a, b int) int { return a * b }, - "div": func(a, b int64) int64 { return a / b }, - "mod": func(a, b int) int { return a % b }, - "ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) }, + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "mul": func(a, b int) int { return a * b }, + "div": func(a, b int64) int64 { return a / b }, + // durationSeconds 将 time.Duration 转为整秒(模板中无法做类型转换)。 + "durationSeconds": func(d time.Duration) int64 { return int64(d / time.Second) }, + "mod": func(a, b int) int { return a % b }, + "ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) }, "seq": func(n int) []int { result := make([]int, n) for i := 0; i < n; i++ { @@ -172,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) (*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, notify smtp_server.NewMailNotify) (*WebServer, error) { if err := config.ValidateSecretKey(cfg.SecretKey); err != nil { return nil, err } @@ -218,6 +225,8 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag banCfg: banCfg, caddyDataDir: caddyCfg.DataDir, outbound: ob, + hub: hub, + notify: notify, } ws.registerRoutes() @@ -227,8 +236,8 @@ 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) - adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound, ws.cfg.ProtocolLogKeepDays) + mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound, ws.notify) + 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 ws.engine.Use(middleware.BanMiddleware(ws.stores)) @@ -298,6 +307,7 @@ func (ws *WebServer) registerRoutes() { admin.POST("/bans/:id/unban", adminHandler.UnbanIP) admin.GET("/protocol-logs", adminHandler.ListProtocolLogs) admin.POST("/protocol-logs/cleanup", adminHandler.CleanupProtocolLogs) + admin.GET("/connections", adminHandler.ListConnections) } } diff --git a/internal/web/session_secret_test.go b/internal/web/session_secret_test.go index a0075d2..e941abf 100644 --- a/internal/web/session_secret_test.go +++ b/internal/web/session_secret_test.go @@ -14,6 +14,7 @@ import ( "time" "mail_go/config" + "mail_go/internal/connhub" "mail_go/internal/db" "mail_go/internal/storage" "mail_go/internal/store" @@ -74,7 +75,7 @@ func newTestWebServer(t *testing.T, secretKey string) (*WebServer, *store.Stores cfg := config.WebConfig{Addr: "127.0.0.1:0", SecretKey: secretKey, CookieSecure: true} ws, err := NewWebServer(cfg, stores, attStorage, config.StorageConfig{BaseDir: baseDir}, - config.AuthConfig{}, config.BanConfig{MaxFailAttempts: 100}, config.CaddyConfig{}, nil) + config.AuthConfig{}, config.BanConfig{MaxFailAttempts: 100}, config.CaddyConfig{}, nil, connhub.New(), nil) if err != nil { t.Fatalf("NewWebServer: %v", err) } @@ -188,7 +189,7 @@ func TestNewWebServerRejectsBadSecretKeys(t *testing.T) { t.Run(tc.name, func(t *testing.T) { _, err := NewWebServer(config.WebConfig{Addr: "127.0.0.1:0", SecretKey: tc.key}, stores, attStorage, config.StorageConfig{BaseDir: baseDir}, - config.AuthConfig{}, config.BanConfig{}, config.CaddyConfig{}, nil) + config.AuthConfig{}, config.BanConfig{}, config.CaddyConfig{}, nil, connhub.New(), nil) if err == nil { t.Fatalf("NewWebServer should reject secret key %q", tc.key) } diff --git a/internal/web/templates/admin/bans.html b/internal/web/templates/admin/bans.html index 5c41dd9..a1774ed 100644 --- a/internal/web/templates/admin/bans.html +++ b/internal/web/templates/admin/bans.html @@ -19,7 +19,8 @@ 所有邮件 外发队列 协议日志 - IP黑名单 + 当前连接 +IP黑名单
diff --git a/internal/web/templates/admin/connections.html b/internal/web/templates/admin/connections.html new file mode 100644 index 0000000..832985d --- /dev/null +++ b/internal/web/templates/admin/connections.html @@ -0,0 +1,94 @@ +{{define "admin_connections"}} + + + + + + 当前连接 - MailGo + + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+

当前连接(SMTP / IMAP / POP3)

+ 每 5 秒自动刷新 +
+ +
+
+

{{.total}}

+

当前连接总数

+
+
+

{{.smtpCount}}

+

SMTP

+
+
+

{{.imapCount}}

+

IMAP

+
+
+

{{.pop3Count}}

+

POP3

+
+
+ +
+ + + + + + + + + + + + + + + + {{range .conns}} + + + + + + + + + + + + {{else}} + + {{end}} + +
ID协议来源 IP端口用户名TLS连接时间时长最后活跃
{{.ID}} + {{if eq .Protocol "smtp"}}SMTP + {{else if eq .Protocol "imap"}}IMAP + {{else}}POP3{{end}} + {{.IP}}{{.Port}}{{if .User}}{{.User}}{{else}}—{{end}}{{if .TLS}}TLS{{else}}明文{{end}}{{.Connected.Format "2006-01-02 15:04:05"}}{{durationSeconds ($.now.Sub .Connected)}}s{{.LastActive.Format "2006-01-02 15:04:05"}}
当前没有活动连接
+
+
+
+
+ + +{{end}} diff --git a/internal/web/templates/admin/dashboard.html b/internal/web/templates/admin/dashboard.html index c1569f8..7d8c159 100644 --- a/internal/web/templates/admin/dashboard.html +++ b/internal/web/templates/admin/dashboard.html @@ -19,6 +19,7 @@ 所有邮件 外发队列 协议日志 + 当前连接 IP黑名单
diff --git a/internal/web/templates/admin/dns_hint.html b/internal/web/templates/admin/dns_hint.html index 51e1012..ab769bb 100644 --- a/internal/web/templates/admin/dns_hint.html +++ b/internal/web/templates/admin/dns_hint.html @@ -19,6 +19,7 @@ 所有邮件 外发队列 协议日志 + 当前连接 IP黑名单
diff --git a/internal/web/templates/admin/domain_form.html b/internal/web/templates/admin/domain_form.html index 46742fd..ef9eb51 100644 --- a/internal/web/templates/admin/domain_form.html +++ b/internal/web/templates/admin/domain_form.html @@ -19,6 +19,7 @@ 所有邮件 外发队列 协议日志 + 当前连接 IP黑名单
diff --git a/internal/web/templates/admin/domains.html b/internal/web/templates/admin/domains.html index cedc338..8ed3a66 100644 --- a/internal/web/templates/admin/domains.html +++ b/internal/web/templates/admin/domains.html @@ -19,6 +19,7 @@ 所有邮件 外发队列 协议日志 + 当前连接 IP黑名单
diff --git a/internal/web/templates/admin/mail_view.html b/internal/web/templates/admin/mail_view.html index fe9ceb4..0f136f3 100644 --- a/internal/web/templates/admin/mail_view.html +++ b/internal/web/templates/admin/mail_view.html @@ -28,6 +28,7 @@ 所有邮件 外发队列 协议日志 + 当前连接 IP黑名单
diff --git a/internal/web/templates/admin/mails.html b/internal/web/templates/admin/mails.html index e1d3fb2..3161ff6 100644 --- a/internal/web/templates/admin/mails.html +++ b/internal/web/templates/admin/mails.html @@ -19,6 +19,7 @@ 所有邮件 外发队列 协议日志 + 当前连接 IP黑名单
diff --git a/internal/web/templates/admin/outbound.html b/internal/web/templates/admin/outbound.html index 555b8d0..4eaf1ae 100644 --- a/internal/web/templates/admin/outbound.html +++ b/internal/web/templates/admin/outbound.html @@ -19,6 +19,7 @@ 所有邮件 外发队列 协议日志 + 当前连接 IP黑名单
diff --git a/internal/web/templates/admin/protocol_logs.html b/internal/web/templates/admin/protocol_logs.html index ced23fc..b918f4a 100644 --- a/internal/web/templates/admin/protocol_logs.html +++ b/internal/web/templates/admin/protocol_logs.html @@ -19,7 +19,8 @@ 所有邮件 外发队列 协议日志 - IP黑名单 + 当前连接 +IP黑名单
diff --git a/internal/web/templates/admin/user_form.html b/internal/web/templates/admin/user_form.html index 7141e2f..e2d04b7 100644 --- a/internal/web/templates/admin/user_form.html +++ b/internal/web/templates/admin/user_form.html @@ -19,6 +19,7 @@ 所有邮件 外发队列 协议日志 + 当前连接 IP黑名单
diff --git a/internal/web/templates/admin/users.html b/internal/web/templates/admin/users.html index 18fb348..3173c2f 100644 --- a/internal/web/templates/admin/users.html +++ b/internal/web/templates/admin/users.html @@ -19,6 +19,7 @@ 所有邮件 外发队列 协议日志 + 当前连接 IP黑名单
diff --git a/main.go b/main.go index 3585635..7b87dcc 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ import ( "time" "mail_go/config" + "mail_go/internal/connhub" "mail_go/internal/db" "mail_go/internal/imap_server" "mail_go/internal/outbound" @@ -237,8 +238,27 @@ func main() { fmt.Println("外发邮件投递未启用(outbound.max_per_day = 0)") } - // 7. Start SMTP server - smtpSrv := smtp_server.NewSMTPServer(cfg.SMTP, stores, attStorage, outboundMgr, smtpTLS, cfg.Ban) + // 6. 连接注册中心(后台「当前连接」页 + IMAP 新邮件推送) + connHub := connhub.New() + + // 7. Start IMAP server(先于 SMTP 创建,SMTP 投递成功时通知其推送) + imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores, imapTLS, cfg.Ban, connHub) + go func() { + if err := imapSrv.Start(); err != nil { + log.Printf("IMAP 服务启动失败: %v", err) + } + }() + // Start IMAPS if TLS is configured + if cfg.IMAP.TLSCert != "" && cfg.IMAP.TLSKey != "" { + go func() { + if err := imapSrv.StartTLS(); err != nil { + log.Printf("IMAPS 服务启动失败: %v", err) + } + }() + } + + // 8. Start SMTP server(本地投递成功后触发 IMAP 新邮件推送) + smtpSrv := smtp_server.NewSMTPServer(cfg.SMTP, stores, attStorage, outboundMgr, smtpTLS, cfg.Ban, connHub, imapSrv.NotifyNewMessage) go func() { if err := smtpSrv.Start(); err != nil { log.Printf("SMTP 服务启动失败: %v", err) @@ -258,24 +278,8 @@ func main() { }() } - // 7. Start IMAP server - imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores, imapTLS, cfg.Ban) - go func() { - if err := imapSrv.Start(); err != nil { - log.Printf("IMAP 服务启动失败: %v", err) - } - }() - // Start IMAPS if TLS is configured - if cfg.IMAP.TLSCert != "" && cfg.IMAP.TLSKey != "" { - go func() { - if err := imapSrv.StartTLS(); err != nil { - log.Printf("IMAPS 服务启动失败: %v", err) - } - }() - } - - // 8. Start POP3 server - pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS, cfg.Ban) + // 9. Start POP3 server + pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS, cfg.Ban, connHub) go func() { if err := pop3Srv.Start(); err != nil { log.Printf("POP3 服务启动失败: %v", err) @@ -290,8 +294,8 @@ func main() { }() } - // 10. Start Web server - webServer, err := web.NewWebServer(cfg.Web, stores, attStorage, cfg.Storage, cfg.Auth, cfg.Ban, cfg.Caddy, outboundMgr) + // 10. Start Web server(本地写信投递成功后同样触发 IMAP 新邮件推送) + webServer, err := web.NewWebServer(cfg.Web, stores, attStorage, cfg.Storage, cfg.Auth, cfg.Ban, cfg.Caddy, outboundMgr, connHub, imapSrv.NotifyNewMessage) if err != nil { log.Fatalf("Web 服务初始化失败: %v", err) }