feat(imap): 新邮件实时推送(IDLE)+ 后台当前连接监控
- IMAP 推送:imapBackend 实现 backend.BackendUpdater,SMTP 本地投递 与 Web 写信投递成功后 NotifyNewMessage,挂起 IDLE 的客户端即时收到 新邮件 FETCH 通知(按用户名+INBOX 过滤广播,通道满非阻塞丢弃) - 当前连接:新增 internal/connhub 连接注册中心,SMTP/IMAP/POP3 三协议 注册/注销/用户名/TLS/活跃时间追踪;后台新增「当前连接」页 (/admin/connections,统计卡片+连接表格,每 5 秒自动刷新) - 新增测试:connhub 并发安全、推送内容/非阻塞/nil 安全、 后台页面渲染;全量 -race 通过
This commit is contained in:
@@ -10,6 +10,8 @@ Web 前端采用 QQ 邮箱风格的布局:顶部导航 + 左侧文件夹栏 +
|
|||||||
- **Web 邮箱**:QQ 邮箱风格界面,支持收件箱 / 已发送 / 草稿箱、未读角标与搜索过滤、全选 / 批量删除、发件人头像、富文本编辑(Quill.js)、附件上传/下载
|
- **Web 邮箱**:QQ 邮箱风格界面,支持收件箱 / 已发送 / 草稿箱、未读角标与搜索过滤、全选 / 批量删除、发件人头像、富文本编辑(Quill.js)、附件上传/下载
|
||||||
- **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、外发队列管理、IP 封禁管理、仪表盘统计
|
- **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、外发队列管理、IP 封禁管理、仪表盘统计
|
||||||
- **协议调用日志**:SMTP / IMAP / POP3 每次连接自动记录来源 IP、用户名、成功/失败、失败原因与操作摘要,可按协议/状态/IP/用户名/时间筛选,用于分析密码爆破、中继滥用等攻击行为(默认保留 30 天,自动清理)
|
- **协议调用日志**:SMTP / IMAP / POP3 每次连接自动记录来源 IP、用户名、成功/失败、失败原因与操作摘要,可按协议/状态/IP/用户名/时间筛选,用于分析密码爆破、中继滥用等攻击行为(默认保留 30 天,自动清理)
|
||||||
|
- **IMAP 新邮件推送**:本地投递(SMTP/Web 写信)成功后实时推送,挂起 IDLE 的客户端即时收到新邮件通知(无需轮询)
|
||||||
|
- **当前连接监控**:管理后台实时查看 SMTP/IMAP/POP3 活动连接(来源 IP、用户名、TLS、时长),每 5 秒自动刷新
|
||||||
- **外部认证**:OAuth2(Google / GitHub)、LDAP(可选,默认关闭)
|
- **外部认证**:OAuth2(Google / GitHub)、LDAP(可选,默认关闭)
|
||||||
- **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP(**阶段性封禁**:前 3 次达到失败阈值只计数,第 4 次起封禁并按档位递增 30分钟 → 3小时 → 3个月 → 半年,成功登录清零)、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封
|
- **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP(**阶段性封禁**:前 3 次达到失败阈值只计数,第 4 次起封禁并按档位递增 30分钟 → 3小时 → 3个月 → 半年,成功登录清零)、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封
|
||||||
- **多数据库**:默认 SQLite,可切换 MySQL
|
- **多数据库**:默认 SQLite,可切换 MySQL
|
||||||
@@ -404,6 +406,7 @@ mailgo/
|
|||||||
│ │ ├── server.go # IMAP 服务
|
│ │ ├── server.go # IMAP 服务
|
||||||
│ │ └── backend.go # IMAP 后端
|
│ │ └── backend.go # IMAP 后端
|
||||||
│ ├── pop3_server/server.go # POP3 服务
|
│ ├── pop3_server/server.go # POP3 服务
|
||||||
|
│ ├── connhub/hub.go # 协议连接注册中心(当前连接监控)
|
||||||
│ ├── storage/attachment.go # 附件文件存储
|
│ ├── storage/attachment.go # 附件文件存储
|
||||||
│ ├── dkim/keys.go # DKIM 密钥生成
|
│ ├── dkim/keys.go # DKIM 密钥生成
|
||||||
│ ├── auth/
|
│ ├── auth/
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mail_go/config"
|
"mail_go/config"
|
||||||
|
"mail_go/internal/connhub"
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
"mail_go/internal/mailutil"
|
"mail_go/internal/mailutil"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
@@ -24,11 +25,62 @@ import (
|
|||||||
|
|
||||||
// ---------- imapBackend ----------
|
// ---------- imapBackend ----------
|
||||||
|
|
||||||
// imapBackend implements backend.Backend.
|
// imapBackend implements backend.Backend and backend.BackendUpdater.
|
||||||
type imapBackend struct {
|
type imapBackend struct {
|
||||||
stores *store.Stores
|
stores *store.Stores
|
||||||
banCfg config.BanConfig
|
banCfg config.BanConfig
|
||||||
port int
|
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.
|
// 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)
|
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{
|
return &imapUser{
|
||||||
stores: b.stores,
|
stores: b.stores,
|
||||||
id: user.ID,
|
id: user.ID,
|
||||||
@@ -65,6 +123,7 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string)
|
|||||||
logID: logID,
|
logID: logID,
|
||||||
clientIP: clientIP,
|
clientIP: clientIP,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
|
conn: conn,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +157,7 @@ type imapUser struct {
|
|||||||
logID uint
|
logID uint
|
||||||
clientIP string
|
clientIP string
|
||||||
startedAt time.Time
|
startedAt time.Time
|
||||||
|
conn *connhub.Conn
|
||||||
}
|
}
|
||||||
|
|
||||||
// Username returns the user's email address.
|
// 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 {
|
if err := u.stores.ProtocolLogs.UpdateDuration(u.logID, durationMs); err != nil {
|
||||||
log.Printf("IMAP: 更新协议日志失败: %v", err)
|
log.Printf("IMAP: 更新协议日志失败: %v", err)
|
||||||
}
|
}
|
||||||
|
u.conn.Close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: "<new-1@other.com>",
|
||||||
|
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) // 空消息
|
||||||
|
}
|
||||||
@@ -6,8 +6,11 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"mail_go/config"
|
"mail_go/config"
|
||||||
|
"mail_go/internal/connhub"
|
||||||
|
"mail_go/internal/db"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
"mail_go/internal/tlsutil"
|
"mail_go/internal/tlsutil"
|
||||||
|
|
||||||
@@ -21,19 +24,56 @@ type IMAPServer struct {
|
|||||||
cfg config.IMAPConfig
|
cfg config.IMAPConfig
|
||||||
banCfg config.BanConfig
|
banCfg config.BanConfig
|
||||||
tlsLoader *tlsutil.Loader
|
tlsLoader *tlsutil.Loader
|
||||||
|
hub *connhub.Hub
|
||||||
|
|
||||||
|
beMu sync.Mutex
|
||||||
|
bes []*imapBackend // 各监听器(明文/TLS)的 backend,用于新邮件推送
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIMAPServer creates a new IMAP server instance. tlsLoader may be nil
|
// NewIMAPServer creates a new IMAP server instance. tlsLoader may be nil
|
||||||
// when TLS is not configured.
|
// 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{
|
return &IMAPServer{
|
||||||
stores: stores,
|
stores: stores,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
banCfg: banCfg,
|
banCfg: banCfg,
|
||||||
tlsLoader: tlsLoader,
|
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) {
|
func (s *IMAPServer) tlsConfig() (*tls.Config, error) {
|
||||||
if s.tlsLoader == nil {
|
if s.tlsLoader == nil {
|
||||||
return nil, fmt.Errorf("IMAP TLS certificate or key not configured")
|
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.
|
// newServer creates a configured imapserver.Server with the given address.
|
||||||
func (s *IMAPServer) newServer(addr string, tlsConfig *tls.Config) *imapserver.Server {
|
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 := imapserver.New(be)
|
||||||
srv.Addr = addr
|
srv.Addr = addr
|
||||||
srv.TLSConfig = tlsConfig
|
srv.TLSConfig = tlsConfig
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mail_go/config"
|
"mail_go/config"
|
||||||
|
"mail_go/internal/connhub"
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
"mail_go/internal/tlsutil"
|
"mail_go/internal/tlsutil"
|
||||||
@@ -24,13 +25,14 @@ type POP3Server struct {
|
|||||||
cfg config.POP3Config
|
cfg config.POP3Config
|
||||||
banCfg config.BanConfig
|
banCfg config.BanConfig
|
||||||
tlsLoader *tlsutil.Loader
|
tlsLoader *tlsutil.Loader
|
||||||
|
hub *connhub.Hub
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPOP3Server creates a new POP3 server instance. tlsLoader may be nil
|
// NewPOP3Server creates a new POP3 server instance. tlsLoader may be nil
|
||||||
// when TLS is not configured.
|
// when TLS is not configured.
|
||||||
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig) *POP3Server {
|
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}
|
return &POP3Server{stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *POP3Server) tlsConfig() (*tls.Config, error) {
|
func (s *POP3Server) tlsConfig() (*tls.Config, error) {
|
||||||
@@ -133,6 +135,9 @@ func (s *POP3Server) handleConn(conn net.Conn, port int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 连接追踪:注册到当前连接中心,连接结束时注销
|
||||||
|
activeConn := s.hub.Register("pop3", clientIP, port, false)
|
||||||
|
|
||||||
// 会话状态(供协议日志汇总)
|
// 会话状态(供协议日志汇总)
|
||||||
var (
|
var (
|
||||||
authUser *db.User
|
authUser *db.User
|
||||||
@@ -147,6 +152,12 @@ func (s *POP3Server) handleConn(conn net.Conn, port int) {
|
|||||||
var deleted map[int]bool
|
var deleted map[int]bool
|
||||||
tlsActive := false
|
tlsActive := false
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if activeConn != nil {
|
||||||
|
activeConn.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
sendResponse(conn, "+OK MailGo POP3 server ready")
|
sendResponse(conn, "+OK MailGo POP3 server ready")
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -163,6 +174,7 @@ func (s *POP3Server) handleConn(conn net.Conn, port int) {
|
|||||||
parts := strings.SplitN(line, " ", 2)
|
parts := strings.SplitN(line, " ", 2)
|
||||||
cmd := strings.ToUpper(parts[0])
|
cmd := strings.ToUpper(parts[0])
|
||||||
commandCount[cmd]++
|
commandCount[cmd]++
|
||||||
|
activeConn.Touch()
|
||||||
arg := ""
|
arg := ""
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
arg = strings.TrimSpace(parts[1])
|
arg = strings.TrimSpace(parts[1])
|
||||||
@@ -186,6 +198,7 @@ func (s *POP3Server) handleConn(conn net.Conn, port int) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
authFailReason = ""
|
authFailReason = ""
|
||||||
|
activeConn.SetUser(authUsername)
|
||||||
}
|
}
|
||||||
case "STAT":
|
case "STAT":
|
||||||
s.handleSTAT(conn, messages, deleted)
|
s.handleSTAT(conn, messages, deleted)
|
||||||
@@ -233,6 +246,7 @@ func (s *POP3Server) handleConn(conn net.Conn, port int) {
|
|||||||
conn = tlsConn
|
conn = tlsConn
|
||||||
reader = bufio.NewReader(conn)
|
reader = bufio.NewReader(conn)
|
||||||
tlsActive = true
|
tlsActive = true
|
||||||
|
activeConn.SetTLS(true)
|
||||||
case "TOP":
|
case "TOP":
|
||||||
s.handleTOP(conn, arg, messages, deleted)
|
s.handleTOP(conn, arg, messages, deleted)
|
||||||
case "UIDL":
|
case "UIDL":
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mail_go/config"
|
"mail_go/config"
|
||||||
|
"mail_go/internal/connhub"
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
"mail_go/internal/mailutil"
|
"mail_go/internal/mailutil"
|
||||||
"mail_go/internal/outbound"
|
"mail_go/internal/outbound"
|
||||||
@@ -32,6 +33,10 @@ const (
|
|||||||
smtpModeImplicitTLS
|
smtpModeImplicitTLS
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NewMailNotify 是本地新邮件投递完成后的通知回调(IMAP 推送用),
|
||||||
|
// userEmail 为收件人完整邮箱,msg 为已入库的邮件。
|
||||||
|
type NewMailNotify func(userEmail string, msg *db.Message)
|
||||||
|
|
||||||
// SMTPServer wraps go-smtp servers and provides local mail delivery.
|
// SMTPServer wraps go-smtp servers and provides local mail delivery.
|
||||||
type SMTPServer struct {
|
type SMTPServer struct {
|
||||||
stores *store.Stores
|
stores *store.Stores
|
||||||
@@ -40,12 +45,14 @@ type SMTPServer struct {
|
|||||||
cfg config.SMTPConfig
|
cfg config.SMTPConfig
|
||||||
banCfg config.BanConfig
|
banCfg config.BanConfig
|
||||||
tlsLoader *tlsutil.Loader
|
tlsLoader *tlsutil.Loader
|
||||||
|
hub *connhub.Hub
|
||||||
|
notify NewMailNotify // 本地投递成功通知(IMAP 新邮件推送),可空
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSMTPServer creates a new SMTP server instance. tlsLoader may be nil
|
// NewSMTPServer creates a new SMTP server instance. tlsLoader may be nil
|
||||||
// when TLS is not configured.
|
// 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 {
|
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}
|
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) {
|
func (s *SMTPServer) tlsConfig() (*tls.Config, error) {
|
||||||
@@ -110,16 +117,25 @@ type smtpBackend struct {
|
|||||||
|
|
||||||
// NewSession creates a new SMTP session for the incoming connection.
|
// NewSession creates a new SMTP session for the incoming connection.
|
||||||
func (be *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
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{
|
return &smtpSession{
|
||||||
backend: be,
|
backend: be,
|
||||||
mode: be.mode,
|
mode: be.mode,
|
||||||
rcpts: make([]string, 0),
|
rcpts: make([]string, 0),
|
||||||
clientIP: store.ClientIPFromAddr(c.Conn().RemoteAddr()),
|
clientIP: clientIP,
|
||||||
startedAt: time.Now(),
|
startedAt: time.Now(),
|
||||||
port: be.server.sessionPort(be.mode),
|
port: be.server.sessionPort(be.mode),
|
||||||
|
conn: conn,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tlsActive 判断当前连接是否处于 TLS 加密状态(implicit TLS 或 STARTTLS)。
|
||||||
|
func (s *SMTPServer) tlsActive(c *smtp.Conn) bool {
|
||||||
|
_, ok := c.TLSConnectionState()
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
// sessionPort 返回该会话监听的端口号(区分明文/TLS/提交端口),解析失败返回 0。
|
// sessionPort 返回该会话监听的端口号(区分明文/TLS/提交端口),解析失败返回 0。
|
||||||
func (s *SMTPServer) sessionPort(mode smtpMode) int {
|
func (s *SMTPServer) sessionPort(mode smtpMode) int {
|
||||||
addr := s.cfg.Addr
|
addr := s.cfg.Addr
|
||||||
@@ -163,6 +179,9 @@ type smtpSession struct {
|
|||||||
failReason string // 首个失败原因
|
failReason string // 首个失败原因
|
||||||
msgCount int // 成功处理的邮件数(本地投递 + 外发队列)
|
msgCount int // 成功处理的邮件数(本地投递 + 外发队列)
|
||||||
detailParts []string
|
detailParts []string
|
||||||
|
|
||||||
|
// 连接追踪
|
||||||
|
conn *connhub.Conn
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthMechanisms returns supported SMTP AUTH mechanisms.
|
// 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 {
|
return sasl.NewPlainServer(func(identity, username, password string) error {
|
||||||
s.authTried = true
|
s.authTried = true
|
||||||
s.authUsername = username
|
s.authUsername = username
|
||||||
|
s.conn.Touch()
|
||||||
// 已封禁 IP 一律拒绝认证(防协议层暴力破解)
|
// 已封禁 IP 一律拒绝认证(防协议层暴力破解)
|
||||||
if banned, _ := s.backend.server.stores.Bans.IsBanned(s.clientIP); banned {
|
if banned, _ := s.backend.server.stores.Bans.IsBanned(s.clientIP); banned {
|
||||||
s.recordFail("IP已被封禁")
|
s.recordFail("IP已被封禁")
|
||||||
@@ -227,6 +247,9 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
|
|||||||
s.userID = user.ID
|
s.userID = user.ID
|
||||||
s.user = user
|
s.user = user
|
||||||
s.email = user.Username + "@" + domainName
|
s.email = user.Username + "@" + domainName
|
||||||
|
if s.conn != nil {
|
||||||
|
s.conn.SetUser(s.email)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -293,6 +316,7 @@ func (s *smtpSession) localUserByEmail(email string) (*db.User, error) {
|
|||||||
// External recipients (authenticated sessions only) are queued for
|
// External recipients (authenticated sessions only) are queued for
|
||||||
// outbound delivery.
|
// outbound delivery.
|
||||||
func (s *smtpSession) Data(r io.Reader) error {
|
func (s *smtpSession) Data(r io.Reader) error {
|
||||||
|
s.conn.Touch()
|
||||||
if len(s.rcpts) == 0 {
|
if len(s.rcpts) == 0 {
|
||||||
s.recordFail("未指定收件人")
|
s.recordFail("未指定收件人")
|
||||||
return fmt.Errorf("no accepted recipients")
|
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)
|
log.Printf("SMTP: recipient not found %s, skipping", rcpt)
|
||||||
continue
|
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)
|
log.Printf("SMTP: failed to create message for %s: %v", rcpt, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Printf("SMTP: message delivered to %s", rcpt)
|
log.Printf("SMTP: message delivered to %s", rcpt)
|
||||||
localDelivered++
|
localDelivered++
|
||||||
|
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
|
||||||
|
if notify := s.backend.server.notify; notify != nil && msg != nil {
|
||||||
|
notify(user.Username+"@"+user.Domain.Name, msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.msgCount += localDelivered
|
s.msgCount += localDelivered
|
||||||
|
|
||||||
@@ -352,7 +381,7 @@ func (s *smtpSession) Data(r io.Reader) error {
|
|||||||
s.msgCount += externalQueued
|
s.msgCount += externalQueued
|
||||||
|
|
||||||
if s.authenticated && s.userID != 0 && s.mode != smtpModeInbound {
|
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)
|
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
|
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{
|
msg := &db.Message{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
MessageID: parsed.messageID,
|
MessageID: parsed.messageID,
|
||||||
@@ -470,7 +499,7 @@ func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTP
|
|||||||
Date: parsed.date,
|
Date: parsed.date,
|
||||||
}
|
}
|
||||||
if err := s.backend.server.stores.Mails.Create(msg); err != nil {
|
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
|
// 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)
|
_ = 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.
|
// 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.
|
// Logout is called when the SMTP connection is closed.
|
||||||
func (s *smtpSession) Logout() error {
|
func (s *smtpSession) Logout() error {
|
||||||
s.writeProtocolLog()
|
s.writeProtocolLog()
|
||||||
|
if s.conn != nil {
|
||||||
|
s.conn.Close()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func TestSaveMessagePersistsAttachments(t *testing.T) {
|
|||||||
t.Fatalf("create user: %v", err)
|
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)
|
t.Fatalf("saveMessage: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mail_go/internal/caddycert"
|
"mail_go/internal/caddycert"
|
||||||
|
"mail_go/internal/connhub"
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
"mail_go/internal/dkim"
|
"mail_go/internal/dkim"
|
||||||
"mail_go/internal/outbound"
|
"mail_go/internal/outbound"
|
||||||
@@ -32,12 +33,38 @@ type AdminHandler struct {
|
|||||||
outbound *outbound.Manager
|
outbound *outbound.Manager
|
||||||
// protocolLogKeepDays SMTP/IMAP/POP3 协议日志保留天数(配置文件 [web])
|
// protocolLogKeepDays SMTP/IMAP/POP3 协议日志保留天数(配置文件 [web])
|
||||||
protocolLogKeepDays int
|
protocolLogKeepDays int
|
||||||
|
// hub 当前协议连接注册中心(「当前连接」页)
|
||||||
|
hub *connhub.Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminHandler creates a new AdminHandler with the given stores, attachment
|
// NewAdminHandler creates a new AdminHandler with the given stores, attachment
|
||||||
// storage, TLS directory, Caddy data directory and outbound delivery manager.
|
// 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 {
|
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}
|
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.
|
// Dashboard renders the admin dashboard with summary statistics.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
"mail_go/internal/outbound"
|
"mail_go/internal/outbound"
|
||||||
|
"mail_go/internal/smtp_server"
|
||||||
"mail_go/internal/storage"
|
"mail_go/internal/storage"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
|
|
||||||
@@ -49,12 +50,14 @@ type MailHandler struct {
|
|||||||
stores *store.Stores
|
stores *store.Stores
|
||||||
storage *storage.AttachmentStorage
|
storage *storage.AttachmentStorage
|
||||||
outbound *outbound.Manager
|
outbound *outbound.Manager
|
||||||
|
// notify 本地投递成功通知(IMAP 新邮件推送),可空
|
||||||
|
notify smtp_server.NewMailNotify
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMailHandler creates a new MailHandler with the given stores, attachment
|
// NewMailHandler creates a new MailHandler with the given stores, attachment
|
||||||
// storage and outbound delivery manager.
|
// storage and outbound delivery manager.
|
||||||
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager) *MailHandler {
|
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, notify smtp_server.NewMailNotify) *MailHandler {
|
||||||
return &MailHandler{stores: stores, storage: attStorage, outbound: ob}
|
return &MailHandler{stores: stores, storage: attStorage, outbound: ob, notify: notify}
|
||||||
}
|
}
|
||||||
|
|
||||||
// folderCounts returns sidebar badge counts for the current user.
|
// folderCounts returns sidebar badge counts for the current user.
|
||||||
@@ -382,6 +385,10 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
|
||||||
|
if h.notify != nil {
|
||||||
|
h.notify(rcptUser.Username+"@"+rcptUser.Domain.Name, inboxMsg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to Sent folder
|
// Save to Sent folder
|
||||||
|
|||||||
@@ -101,6 +101,24 @@ func TestRenderAllPages(t *testing.T) {
|
|||||||
},
|
},
|
||||||
"keepDays": 30,
|
"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")
|
outDir := os.Getenv("MAILGO_PREVIEW_DIR")
|
||||||
|
|||||||
+19
-9
@@ -14,8 +14,10 @@ import (
|
|||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"mail_go/config"
|
"mail_go/config"
|
||||||
|
"mail_go/internal/connhub"
|
||||||
"mail_go/internal/mailutil"
|
"mail_go/internal/mailutil"
|
||||||
"mail_go/internal/outbound"
|
"mail_go/internal/outbound"
|
||||||
|
"mail_go/internal/smtp_server"
|
||||||
"mail_go/internal/storage"
|
"mail_go/internal/storage"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
"mail_go/internal/web/handlers"
|
"mail_go/internal/web/handlers"
|
||||||
@@ -51,17 +53,22 @@ type WebServer struct {
|
|||||||
banCfg config.BanConfig
|
banCfg config.BanConfig
|
||||||
caddyDataDir string
|
caddyDataDir string
|
||||||
outbound *outbound.Manager
|
outbound *outbound.Manager
|
||||||
|
hub *connhub.Hub
|
||||||
|
// notify 本地投递成功通知(IMAP 新邮件推送),可空
|
||||||
|
notify smtp_server.NewMailNotify
|
||||||
}
|
}
|
||||||
|
|
||||||
// templateFuncs returns custom template functions for rendering.
|
// templateFuncs returns custom template functions for rendering.
|
||||||
func templateFuncs() template.FuncMap {
|
func templateFuncs() template.FuncMap {
|
||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
"add": func(a, b int) int { return a + b },
|
"add": func(a, b int) int { return a + b },
|
||||||
"sub": 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 },
|
"mul": func(a, b int) int { return a * b },
|
||||||
"div": func(a, b int64) int64 { return a / b },
|
"div": func(a, b int64) int64 { return a / b },
|
||||||
"mod": func(a, b int) int { return a % b },
|
// durationSeconds 将 time.Duration 转为整秒(模板中无法做类型转换)。
|
||||||
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
|
"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 {
|
"seq": func(n int) []int {
|
||||||
result := make([]int, n)
|
result := make([]int, n)
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
@@ -172,7 +179,7 @@ func avatarStyle(s string) string {
|
|||||||
|
|
||||||
// NewWebServer creates a new WebServer, initializes the Gin engine,
|
// NewWebServer creates a new WebServer, initializes the Gin engine,
|
||||||
// configures sessions, middleware, and registers all routes.
|
// 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 {
|
if err := config.ValidateSecretKey(cfg.SecretKey); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -218,6 +225,8 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
|||||||
banCfg: banCfg,
|
banCfg: banCfg,
|
||||||
caddyDataDir: caddyCfg.DataDir,
|
caddyDataDir: caddyCfg.DataDir,
|
||||||
outbound: ob,
|
outbound: ob,
|
||||||
|
hub: hub,
|
||||||
|
notify: notify,
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.registerRoutes()
|
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.
|
// registerRoutes sets up all HTTP routes with their handlers and middleware.
|
||||||
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, ws.outbound)
|
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)
|
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
|
// Apply BanMiddleware globally before public routes
|
||||||
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
||||||
@@ -298,6 +307,7 @@ func (ws *WebServer) registerRoutes() {
|
|||||||
admin.POST("/bans/:id/unban", adminHandler.UnbanIP)
|
admin.POST("/bans/:id/unban", adminHandler.UnbanIP)
|
||||||
admin.GET("/protocol-logs", adminHandler.ListProtocolLogs)
|
admin.GET("/protocol-logs", adminHandler.ListProtocolLogs)
|
||||||
admin.POST("/protocol-logs/cleanup", adminHandler.CleanupProtocolLogs)
|
admin.POST("/protocol-logs/cleanup", adminHandler.CleanupProtocolLogs)
|
||||||
|
admin.GET("/connections", adminHandler.ListConnections)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mail_go/config"
|
"mail_go/config"
|
||||||
|
"mail_go/internal/connhub"
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
"mail_go/internal/storage"
|
"mail_go/internal/storage"
|
||||||
"mail_go/internal/store"
|
"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}
|
cfg := config.WebConfig{Addr: "127.0.0.1:0", SecretKey: secretKey, CookieSecure: true}
|
||||||
|
|
||||||
ws, err := NewWebServer(cfg, stores, attStorage, config.StorageConfig{BaseDir: baseDir},
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("NewWebServer: %v", err)
|
t.Fatalf("NewWebServer: %v", err)
|
||||||
}
|
}
|
||||||
@@ -188,7 +189,7 @@ func TestNewWebServerRejectsBadSecretKeys(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
_, err := NewWebServer(config.WebConfig{Addr: "127.0.0.1:0", SecretKey: tc.key},
|
_, err := NewWebServer(config.WebConfig{Addr: "127.0.0.1:0", SecretKey: tc.key},
|
||||||
stores, attStorage, config.StorageConfig{BaseDir: baseDir},
|
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 {
|
if err == nil {
|
||||||
t.Fatalf("NewWebServer should reject secret key %q", tc.key)
|
t.Fatalf("NewWebServer should reject secret key %q", tc.key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||||
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{{define "admin_connections"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<title>当前连接 - MailGo</title>
|
||||||
|
<meta http-equiv="refresh" content="5">
|
||||||
|
{{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" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
|
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||||
|
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||||
|
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||||
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||||
|
<h2>当前连接(SMTP / IMAP / POP3)</h2>
|
||||||
|
<span style="color:#7f8c8d;font-size:13px;">每 5 秒自动刷新</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:24px;">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>{{.total}}</h3>
|
||||||
|
<p>当前连接总数</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>{{.smtpCount}}</h3>
|
||||||
|
<p>SMTP</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>{{.imapCount}}</h3>
|
||||||
|
<p>IMAP</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>{{.pop3Count}}</h3>
|
||||||
|
<p>POP3</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>协议</th>
|
||||||
|
<th>来源 IP</th>
|
||||||
|
<th>端口</th>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>TLS</th>
|
||||||
|
<th>连接时间</th>
|
||||||
|
<th>时长</th>
|
||||||
|
<th>最后活跃</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .conns}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td>
|
||||||
|
{{if eq .Protocol "smtp"}}<span class="badge" style="background:#3498db;color:#fff;">SMTP</span>
|
||||||
|
{{else if eq .Protocol "imap"}}<span class="badge" style="background:#9b59b6;color:#fff;">IMAP</span>
|
||||||
|
{{else}}<span class="badge" style="background:#16a085;color:#fff;">POP3</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{.IP}}</td>
|
||||||
|
<td>{{.Port}}</td>
|
||||||
|
<td>{{if .User}}{{.User}}{{else}}—{{end}}</td>
|
||||||
|
<td>{{if .TLS}}<span class="badge" style="background:#27ae60;color:#fff;">TLS</span>{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">明文</span>{{end}}</td>
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="9" style="text-align:center;color:#7f8c8d;">当前没有活动连接</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||||
|
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||||
|
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||||
|
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||||
|
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<a href="/admin/mails" class="active">所有邮件</a>
|
<a href="/admin/mails" class="active">所有邮件</a>
|
||||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||||
|
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||||
|
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||||
|
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||||
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||||
|
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||||
|
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mail_go/config"
|
"mail_go/config"
|
||||||
|
"mail_go/internal/connhub"
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
"mail_go/internal/imap_server"
|
"mail_go/internal/imap_server"
|
||||||
"mail_go/internal/outbound"
|
"mail_go/internal/outbound"
|
||||||
@@ -237,8 +238,27 @@ func main() {
|
|||||||
fmt.Println("外发邮件投递未启用(outbound.max_per_day = 0)")
|
fmt.Println("外发邮件投递未启用(outbound.max_per_day = 0)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Start SMTP server
|
// 6. 连接注册中心(后台「当前连接」页 + IMAP 新邮件推送)
|
||||||
smtpSrv := smtp_server.NewSMTPServer(cfg.SMTP, stores, attStorage, outboundMgr, smtpTLS, cfg.Ban)
|
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() {
|
go func() {
|
||||||
if err := smtpSrv.Start(); err != nil {
|
if err := smtpSrv.Start(); err != nil {
|
||||||
log.Printf("SMTP 服务启动失败: %v", err)
|
log.Printf("SMTP 服务启动失败: %v", err)
|
||||||
@@ -258,24 +278,8 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Start IMAP server
|
// 9. Start POP3 server
|
||||||
imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores, imapTLS, cfg.Ban)
|
pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS, 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 POP3 server
|
|
||||||
pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS, cfg.Ban)
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := pop3Srv.Start(); err != nil {
|
if err := pop3Srv.Start(); err != nil {
|
||||||
log.Printf("POP3 服务启动失败: %v", err)
|
log.Printf("POP3 服务启动失败: %v", err)
|
||||||
@@ -290,8 +294,8 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Start Web server
|
// 10. Start Web server(本地写信投递成功后同样触发 IMAP 新邮件推送)
|
||||||
webServer, err := web.NewWebServer(cfg.Web, stores, attStorage, cfg.Storage, cfg.Auth, cfg.Ban, cfg.Caddy, outboundMgr)
|
webServer, err := web.NewWebServer(cfg.Web, stores, attStorage, cfg.Storage, cfg.Auth, cfg.Ban, cfg.Caddy, outboundMgr, connHub, imapSrv.NotifyNewMessage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Web 服务初始化失败: %v", err)
|
log.Fatalf("Web 服务初始化失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user