Files
mailgo/internal/pop3_server/server.go
kevin 6a9dcd0285 feat(imap): 多客户端状态实时同步 + 当前连接「断开并封禁」
- 推送扩展:IMAP STORE(已读/星标/\Deleted)推送 FETCH 标志更新、
  EXPUNGE 推送 ExpungeUpdate(删除前序号)、APPEND/COPY/MOVE 推送
  新邮件;POP3 QUIT 删除、Web 标已读/删除同样实时同步到 IMAP 客户端
- Pusher 接口统一 SMTP/POP3/Web 的推送入口,IMAP 内部操作经会话
  通道直接入队(非阻塞,满则丢弃)
- 当前连接页新增「断开并封禁」:connhub 支持断开回调,SMTP/POP3
  关底层连接、IMAP 经 ForEachConn 按地址断开;一键封禁 180 天并
  断开该 IP 全部在线连接,黑名单页可随时解封
- 修复:POP3 PASS 成功后保留完整邮箱(此前被裸用户名覆盖)
- 新增测试:断开/按 IP 断开、flags/expunge 推送内容、POP3 删除推送、
  Web 断开封禁处理器;全量 -race 通过
2026-08-19 19:56:04 +08:00

656 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package pop3_server
import (
"bufio"
"crypto/tls"
"fmt"
"log"
"net"
"strconv"
"strings"
"sync"
"time"
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/imap_server"
"mail_go/internal/store"
"mail_go/internal/tlsutil"
)
// POP3Server implements a simple POP3 mail server over TCP.
type POP3Server struct {
listener net.Listener
stores *store.Stores
cfg config.POP3Config
banCfg config.BanConfig
tlsLoader *tlsutil.Loader
hub *connhub.Hub
pusher imap_server.Pusher // 邮件删除推送(IMAP 客户端同步),可空
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, hub *connhub.Hub, pusher imap_server.Pusher) *POP3Server {
return &POP3Server{stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub, pusher: pusher}
}
func (s *POP3Server) tlsConfig() (*tls.Config, error) {
if s.tlsLoader == nil {
return nil, fmt.Errorf("POP3 TLS certificate or key not configured")
}
// GetCertificate 每次握手按需重载证书,证书更新后无需重启服务
return &tls.Config{GetCertificate: s.tlsLoader.GetCertificate}, nil
}
// Start starts the POP3 server on the configured plain-text port.
func (s *POP3Server) Start() error {
var err error
s.listener, err = net.Listen("tcp", s.cfg.Addr)
if err != nil {
return fmt.Errorf("POP3 listen failed: %w", err)
}
port := parseAddrPort(s.cfg.Addr)
log.Printf("POP3 server listening on %s", s.cfg.Addr)
s.wg.Add(1)
go func() {
defer s.wg.Done()
for {
conn, err := s.listener.Accept()
if err != nil {
return
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.handleConn(conn, port)
}()
}
}()
return nil
}
// StartTLS starts the POP3 server on the configured TLS port.
func (s *POP3Server) StartTLS() error {
tlsConfig, err := s.tlsConfig()
if err != nil {
return err
}
listener, err := tls.Listen("tcp", s.cfg.TLSAddr, tlsConfig)
if err != nil {
return fmt.Errorf("POP3 TLS listen failed: %w", err)
}
port := parseAddrPort(s.cfg.TLSAddr)
log.Printf("POP3 TLS server listening on %s", s.cfg.TLSAddr)
s.wg.Add(1)
go func() {
defer s.wg.Done()
for {
conn, err := listener.Accept()
if err != nil {
return
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.handleConn(conn, port)
}()
}
}()
return nil
}
// parseAddrPort 从监听地址解析端口号,失败返回 0。
func parseAddrPort(addr string) int {
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return 0
}
port, err := strconv.Atoi(portStr)
if err != nil {
return 0
}
return port
}
// handleConn handles a single POP3 client connection.
func (s *POP3Server) handleConn(conn net.Conn, port int) {
defer conn.Close()
conn.SetDeadline(time.Now().Add(10 * time.Minute))
clientIP := store.ClientIPFromAddr(conn.RemoteAddr())
startedAt := time.Now()
// 已封禁 IP 直接拒绝(防协议层暴力破解)
if banned, _ := s.stores.Bans.IsBanned(clientIP); banned {
sendResponse(conn, "-ERR access denied")
s.writeProtocolLog(port, clientIP, "", false, "IP已被封禁", "连接被拒绝", 0, 0, startedAt)
return
}
// 连接追踪:注册到当前连接中心,连接结束时注销;
// 强制断开:关闭底层连接(STLS 后 conn 变量已指向 tlsConn,同样生效)。
activeConn := s.hub.Register("pop3", clientIP, port, false)
if activeConn != nil {
activeConn.SetDisconnect(func() { _ = conn.Close() })
}
// 会话状态(供协议日志汇总)
var (
authUser *db.User
authUsername string
authFailReason string
commandCount = make(map[string]int)
deletedCount int
)
reader := bufio.NewReader(conn)
var messages []pop3Message
var deleted map[int]bool
tlsActive := false
defer func() {
if activeConn != nil {
activeConn.Close()
}
}()
sendResponse(conn, "+OK MailGo POP3 server ready")
for {
line, err := reader.ReadString('\n')
if err != nil {
break
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, " ", 2)
cmd := strings.ToUpper(parts[0])
commandCount[cmd]++
activeConn.Touch()
arg := ""
if len(parts) > 1 {
arg = strings.TrimSpace(parts[1])
}
authenticated := authUser != nil && authUser.ID != 0
if !authenticated && requiresAuth(cmd) {
sendResponse(conn, "-ERR authentication required")
continue
}
switch cmd {
case "USER":
authUsername = arg
authUser, messages, deleted = s.handleUSER(conn, arg, authUser)
case "PASS":
authUser, messages, deleted = s.handlePASS(conn, arg, authUser)
if authUser == nil || authUser.ID == 0 {
if authFailReason == "" {
authFailReason = "用户名或密码错误"
}
} else {
authFailReason = ""
activeConn.SetUser(authUsername)
}
case "STAT":
s.handleSTAT(conn, messages, deleted)
case "LIST":
s.handleLIST(conn, arg, messages, deleted)
case "RETR":
s.handleRETR(conn, arg, messages, deleted)
case "DELE":
s.handleDELE(conn, arg, messages, deleted)
case "NOOP":
sendResponse(conn, "+OK")
case "RSET":
deleted = make(map[int]bool)
sendResponse(conn, "+OK")
case "QUIT":
deletedCount = s.expungeDeleted(messages, deleted, authUser)
sendResponse(conn, "+OK MailGo POP3 server signing off")
s.writeProtocolLog(port, clientIP, authUsername, authUser != nil && authUser.ID != 0, authFailReason,
pop3CommandDetail(commandCount, deletedCount), deletedCount,
time.Since(startedAt).Milliseconds(), time.Now())
return
case "CAPA":
s.handleCAPA(conn, tlsActive)
case "STLS":
if authenticated {
sendResponse(conn, "-ERR STLS not allowed after authentication")
continue
}
if tlsActive {
sendResponse(conn, "-ERR TLS already active")
continue
}
tlsConfig, err := s.tlsConfig()
if err != nil {
sendResponse(conn, "-ERR TLS not available")
continue
}
sendResponse(conn, "+OK Begin TLS negotiation")
tlsConn := tls.Server(conn, tlsConfig)
if err := tlsConn.Handshake(); err != nil {
s.writeProtocolLog(port, clientIP, authUsername, false, "TLS 握手失败",
pop3CommandDetail(commandCount, 0), 0, time.Since(startedAt).Milliseconds(), time.Now())
return
}
conn = tlsConn
reader = bufio.NewReader(conn)
tlsActive = true
activeConn.SetTLS(true)
case "TOP":
s.handleTOP(conn, arg, messages, deleted)
case "UIDL":
s.handleUIDL(conn, arg, messages, deleted)
default:
sendResponse(conn, "-ERR unknown command")
}
}
// 连接异常结束(未 QUIT
success := authUser != nil && authUser.ID != 0 && authFailReason == ""
if success && authFailReason == "" && authUsername == "" && len(commandCount) == 0 {
success = true
}
s.writeProtocolLog(port, clientIP, authUsername, success, authFailReason,
pop3CommandDetail(commandCount, 0), 0, time.Since(startedAt).Milliseconds(), time.Now())
}
// pop3CommandDetail 汇总会话中执行的命令为可读摘要(计数,忽略 NOOP/CAPA)。
func pop3CommandDetail(counts map[string]int, deletedCount int) string {
var parts []string
for _, c := range []string{"USER", "PASS", "STAT", "LIST", "RETR", "TOP", "UIDL", "DELE", "RSET", "STLS", "QUIT"} {
n := counts[c]
if n == 0 {
continue
}
if n == 1 {
parts = append(parts, c)
} else {
parts = append(parts, fmt.Sprintf("%s×%d", c, n))
}
}
if deletedCount > 0 {
parts = append(parts, fmt.Sprintf("删除%d", deletedCount))
}
if len(parts) == 0 {
return "连接建立,无命令"
}
return strings.Join(parts, " ")
}
// writeProtocolLog 写入一条 POP3 协议调用日志。
func (s *POP3Server) writeProtocolLog(port int, ip, username string, success bool, failReason, detail string, msgCount int, durationMs int64, at time.Time) {
entry := &db.ProtocolLog{
Protocol: db.ProtocolPOP3,
Port: port,
ClientIP: ip,
Username: username,
Success: success,
FailReason: failReason,
Detail: detail,
MsgCount: msgCount,
DurationMs: durationMs,
CreatedAt: at,
}
if err := s.stores.ProtocolLogs.Create(entry); err != nil {
log.Printf("POP3: 写入协议日志失败: %v", err)
}
}
func requiresAuth(cmd string) bool {
switch cmd {
case "STAT", "LIST", "RETR", "DELE", "RSET", "TOP", "UIDL":
return true
default:
return false
}
}
func (s *POP3Server) handleCAPA(conn net.Conn, tlsActive bool) {
sendResponse(conn, "+OK Capability list follows")
sendResponse(conn, "USER")
sendResponse(conn, "TOP")
sendResponse(conn, "UIDL")
sendResponse(conn, "RESP-CODES")
if !tlsActive && s.cfg.TLSCert != "" && s.cfg.TLSKey != "" {
sendResponse(conn, "STLS")
}
sendResponse(conn, ".")
}
// pop3Message holds a message and its computed size for POP3.
type pop3Message struct {
id uint
raw string
size int
message *db.Message
}
// loadMessages loads all INBOX messages for a user.
func (s *POP3Server) loadMessages(user *db.User) []pop3Message {
if user == nil {
return nil
}
dbMsgs, err := s.stores.Mails.ListAllByUserAndFolder(user.ID, "INBOX")
if err != nil {
return nil
}
msgs := make([]pop3Message, 0, len(dbMsgs))
for i := range dbMsgs {
raw := string(buildRawMessage(&dbMsgs[i]))
msgs = append(msgs, pop3Message{
id: dbMsgs[i].ID,
raw: raw,
size: len(normalizePOP3Data(raw)),
message: &dbMsgs[i],
})
}
return msgs
}
// handleUSER processes the USER command.
func (s *POP3Server) handleUSER(conn net.Conn, username string, currentUser *db.User) (*db.User, []pop3Message, map[int]bool) {
if username == "" {
sendResponse(conn, "-ERR missing username")
return currentUser, nil, nil
}
user, err := s.stores.Users.GetByEmail(username)
if err != nil {
sendResponse(conn, "+OK")
return &db.User{Username: username}, nil, nil
}
// 保留完整的邮箱地址作为登录标识(PASS 阶段用 Authenticate 校验),
// user.ID 用于后续加载邮件。
user.Username = username
sendResponse(conn, "+OK")
return user, nil, nil
}
// handlePASS processes the PASS command.
func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (*db.User, []pop3Message, map[int]bool) {
if user == nil {
sendResponse(conn, "-ERR no username given")
return nil, nil, nil
}
clientIP := store.ClientIPFromAddr(conn.RemoteAddr())
authUser, err := s.stores.Users.Authenticate(user.Username, password)
if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多")
sendResponse(conn, "-ERR authentication failed")
return nil, nil, nil
}
// 保留完整邮箱作为登录标识(与 handleUSER 一致),便于推送/日志使用
authUser.Username = user.Username
messages := s.loadMessages(authUser)
deleted := make(map[int]bool)
sendResponse(conn, fmt.Sprintf("+OK authenticated, %d messages", len(messages)))
return authUser, messages, deleted
}
// handleSTAT processes the STAT command.
func (s *POP3Server) handleSTAT(conn net.Conn, messages []pop3Message, deleted map[int]bool) {
count := 0
totalSize := 0
for i, msg := range messages {
if !deleted[i+1] {
count++
totalSize += msg.size
}
}
sendResponse(conn, fmt.Sprintf("+OK %d %d", count, totalSize))
}
// handleLIST processes the LIST command (with optional message number).
func (s *POP3Server) handleLIST(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
if arg == "" {
sendResponse(conn, "+OK message list follows")
for i, msg := range messages {
if !deleted[i+1] {
sendResponse(conn, fmt.Sprintf("%d %d", i+1, msg.size))
}
}
sendResponse(conn, ".")
return
}
num, err := strconv.Atoi(arg)
if err != nil || num < 1 || num > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
if deleted[num] {
sendResponse(conn, "-ERR message deleted")
return
}
sendResponse(conn, fmt.Sprintf("+OK %d %d", num, messages[num-1].size))
}
// handleRETR processes the RETR command.
func (s *POP3Server) handleRETR(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
num, err := strconv.Atoi(arg)
if err != nil || num < 1 || num > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
if deleted[num] {
sendResponse(conn, "-ERR message deleted")
return
}
msg := messages[num-1]
sendResponse(conn, fmt.Sprintf("+OK %d octets", msg.size))
writeDotStuffed(conn, msg.raw, nil)
conn.Write([]byte(".\r\n"))
}
// handleDELE processes the DELE command.
func (s *POP3Server) handleDELE(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
num, err := strconv.Atoi(arg)
if err != nil || num < 1 || num > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
if deleted[num] {
sendResponse(conn, "-ERR message already deleted")
return
}
deleted[num] = true
sendResponse(conn, "+OK message marked for deletion")
}
// handleTOP processes the TOP command (headers + first N lines of body).
func (s *POP3Server) handleTOP(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
parts := strings.Fields(arg)
if len(parts) < 1 {
sendResponse(conn, "-ERR syntax error")
return
}
num, err := strconv.Atoi(parts[0])
if err != nil || num < 1 || num > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
if deleted[num] {
sendResponse(conn, "-ERR message deleted")
return
}
nLines := 0
if len(parts) > 1 {
nLines, _ = strconv.Atoi(parts[1])
}
msg := messages[num-1]
header, body := splitHeaderBody(msg.raw)
sendResponse(conn, "+OK top of message follows")
writeDotStuffed(conn, header, nil)
conn.Write([]byte("\r\n"))
writeDotStuffed(conn, body, &nLines)
conn.Write([]byte(".\r\n"))
}
// handleUIDL processes the UIDL command.
func (s *POP3Server) handleUIDL(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
if arg == "" {
sendResponse(conn, "+OK UIDL list follows")
for i, msg := range messages {
if !deleted[i+1] {
sendResponse(conn, fmt.Sprintf("%d %d", i+1, msg.id))
}
}
sendResponse(conn, ".")
return
}
num, err := strconv.Atoi(arg)
if err != nil || num < 1 || num > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
if deleted[num] {
sendResponse(conn, "-ERR message deleted")
return
}
sendResponse(conn, fmt.Sprintf("+OK %d %d", num, messages[num-1].id))
}
// expungeDeleted actually deletes messages that were marked for deletion,
// returning the number of messages deleted. 删除成功后向 IMAP 客户端推送
// Expunge 通知(序号为删除前在 INBOX 中的位置)。
func (s *POP3Server) expungeDeleted(messages []pop3Message, deleted map[int]bool, user *db.User) int {
if deleted == nil || user == nil || user.ID == 0 {
return 0
}
count := 0
var seqs []uint32
for seqNum, msgDeleted := range deleted {
if msgDeleted && seqNum >= 1 && seqNum <= len(messages) {
if err := s.stores.Mails.Delete(messages[seqNum-1].id); err != nil {
log.Printf("POP3: failed to delete message %d: %v", messages[seqNum-1].id, err)
continue
}
count++
seqs = append(seqs, uint32(seqNum))
}
}
if s.pusher != nil && len(seqs) > 0 && user.Username != "" {
s.pusher.PushExpunged(user.Username, "INBOX", seqs)
}
return count
}
// sendResponse writes a POP3 response line to the connection.
func sendResponse(conn net.Conn, line string) {
conn.Write([]byte(line + "\r\n"))
}
func normalizePOP3Data(raw string) string {
var b strings.Builder
writeDotStuffed(&b, raw, nil)
return b.String()
}
type stringWriter interface {
Write([]byte) (int, error)
}
func writeDotStuffed(w stringWriter, raw string, maxLines *int) {
raw = strings.ReplaceAll(raw, "\r\n", "\n")
raw = strings.ReplaceAll(raw, "\r", "\n")
lines := strings.Split(raw, "\n")
written := 0
for i, line := range lines {
if maxLines != nil && written >= *maxLines {
break
}
if i == len(lines)-1 && line == "" {
break
}
if strings.HasPrefix(line, ".") {
w.Write([]byte("."))
}
w.Write([]byte(line + "\r\n"))
written++
}
}
func splitHeaderBody(raw string) (string, string) {
if idx := strings.Index(raw, "\r\n\r\n"); idx >= 0 {
return raw[:idx], raw[idx+4:]
}
if idx := strings.Index(raw, "\n\n"); idx >= 0 {
return raw[:idx], raw[idx+2:]
}
return raw, ""
}
// buildRawMessage reconstructs a raw RFC822 message from a db.Message.
func buildRawMessage(msg *db.Message) []byte {
if msg.RawData != "" {
return []byte(msg.RawData)
}
var buf strings.Builder
buf.WriteString(fmt.Sprintf("From: %s\r\n", msg.FromAddr))
buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr))
if msg.CcAddr != "" {
buf.WriteString(fmt.Sprintf("Cc: %s\r\n", msg.CcAddr))
}
buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
buf.WriteString(fmt.Sprintf("Date: %s\r\n", msg.Date.Format("Mon, 02 Jan 2006 15:04:05 -0700")))
if msg.MessageID != "" {
buf.WriteString(fmt.Sprintf("Message-ID: %s\r\n", msg.MessageID))
}
buf.WriteString("MIME-Version: 1.0\r\n")
if msg.HtmlBody != "" && msg.TextBody != "" {
boundary := fmt.Sprintf("mailgo_%d", msg.ID)
buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
buf.WriteString("\r\n")
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
buf.WriteString(msg.TextBody)
buf.WriteString("\r\n")
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
buf.WriteString(msg.HtmlBody)
buf.WriteString("\r\n")
buf.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
} else if msg.TextBody != "" {
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
buf.WriteString(msg.TextBody)
} else if msg.HtmlBody != "" {
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
buf.WriteString(msg.HtmlBody)
} else {
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
}
return []byte(buf.String())
}