Files
mailgo/internal/smtp_server/server.go
dsh f48776e89b fix(auth): 协议登录(IMAP/SMTP/POP3)成功时清零失败计数 + 支持裸用户名
问题:ResetFail 只在 Web 登录调用,协议层失败计数只增不减。
合法用户的客户端(手机 APP 用裸用户名重试、配置向导探测、输错
密码等)失败次数持续累积,每达到阈值就触发一次封禁档位,从第 4
次触发起真实封禁 30 分钟+——用户被反复误封,手机端表现为一直
卡在"正在接收邮件"。

修复:
- IMAP/SMTP/POP3 认证成功路径调用 Bans.ResetFail(与 Web 一致)
- 新增 UserStore.AuthenticateLogin:支持裸用户名(如 "kevin"),
  唯一归属时自动解析到其域名;跨域名同名歧义时要求完整邮箱
- 新增 TestAuthenticateLoginBareUsername 单元测试
2026-08-19 10:12:28 -04:00

591 lines
17 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 smtp_server
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"log"
"net"
"strconv"
"strings"
"time"
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/imap_server"
"mail_go/internal/mailutil"
"mail_go/internal/outbound"
"mail_go/internal/storage"
"mail_go/internal/store"
"mail_go/internal/tlsutil"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
)
type smtpMode int
const (
smtpModeInbound smtpMode = iota
smtpModeSubmission
smtpModeImplicitTLS
)
// SMTPServer wraps go-smtp servers and provides local mail delivery.
type SMTPServer struct {
stores *store.Stores
storage *storage.AttachmentStorage
outbound *outbound.Manager
cfg config.SMTPConfig
banCfg config.BanConfig
tlsLoader *tlsutil.Loader
hub *connhub.Hub
pusher imap_server.Pusher // 本地投递成功推送(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, hub *connhub.Hub, pusher imap_server.Pusher) *SMTPServer {
return &SMTPServer{stores: stores, storage: attStorage, outbound: ob, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub, pusher: pusher}
}
func (s *SMTPServer) tlsConfig() (*tls.Config, error) {
if s.tlsLoader == nil {
return nil, fmt.Errorf("SMTP TLS certificate or key not configured")
}
// GetCertificate 每次握手按需重载证书,证书更新后无需重启服务
return &tls.Config{GetCertificate: s.tlsLoader.GetCertificate}, nil
}
func (s *SMTPServer) newServer(addr string, mode smtpMode, tlsConfig *tls.Config) *smtp.Server {
be := &smtpBackend{server: s, mode: mode}
srv := smtp.NewServer(be)
srv.Addr = addr
srv.Domain = s.cfg.Domain
srv.MaxMessageBytes = s.cfg.MaxMessage
srv.AllowInsecureAuth = tlsConfig == nil
srv.ReadTimeout = 60 * time.Second
srv.WriteTimeout = 60 * time.Second
srv.TLSConfig = tlsConfig
return srv
}
// Start starts the inbound SMTP server.
func (s *SMTPServer) Start() error {
tlsConfig, err := s.tlsConfig()
if err != nil {
log.Printf("SMTP STARTTLS 未启用: %v", err)
}
log.Printf("SMTP server listening on %s", s.cfg.Addr)
return s.newServer(s.cfg.Addr, smtpModeInbound, tlsConfig).ListenAndServe()
}
// StartTLS starts the implicit TLS SMTP submission server.
func (s *SMTPServer) StartTLS() error {
tlsConfig, err := s.tlsConfig()
if err != nil {
return err
}
log.Printf("SMTPS server listening on %s", s.cfg.TLSAddr)
return s.newServer(s.cfg.TLSAddr, smtpModeImplicitTLS, tlsConfig).ListenAndServeTLS()
}
// StartSubmission starts the SMTP submission server with STARTTLS support.
func (s *SMTPServer) StartSubmission() error {
tlsConfig, err := s.tlsConfig()
if err != nil {
return err
}
log.Printf("SMTP submission server listening on %s", s.cfg.SubmissionAddr)
return s.newServer(s.cfg.SubmissionAddr, smtpModeSubmission, tlsConfig).ListenAndServe()
}
// smtpBackend implements the smtp.Backend interface.
type smtpBackend struct {
server *SMTPServer
mode smtpMode
}
// 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))
if conn != nil {
// 强制断开:关闭底层连接后 go-smtp 读到 EOF,正常走 Logout 收尾
raw := c.Conn()
conn.SetDisconnect(func() { _ = raw.Close() })
}
return &smtpSession{
backend: be,
mode: be.mode,
rcpts: make([]string, 0),
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
switch mode {
case smtpModeSubmission:
addr = s.cfg.SubmissionAddr
case smtpModeImplicitTLS:
addr = s.cfg.TLSAddr
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return 0
}
port, err := strconv.Atoi(portStr)
if err != nil {
return 0
}
return port
}
// smtpSession implements the smtp.Session interface for handling a single connection.
type smtpSession struct {
backend *smtpBackend
mode smtpMode
from string
rcpts []string
localRcpts []string
externalRcpts []string
authenticated bool
userID uint
email string
user *db.User
clientIP string
// 会话日志累积状态
startedAt time.Time
port int
authTried bool
authOK bool
authUsername string
failReason string // 首个失败原因
msgCount int // 成功处理的邮件数(本地投递 + 外发队列)
detailParts []string
// 连接追踪
conn *connhub.Conn
}
// AuthMechanisms returns supported SMTP AUTH mechanisms.
func (s *smtpSession) AuthMechanisms() []string {
return []string{sasl.Plain}
}
// recordFail 记录会话中第一个失败原因(日志用途,不改变协议行为)。
func (s *smtpSession) recordFail(reason string) {
if s.failReason == "" {
s.failReason = reason
}
}
// recordDetail 追加一条操作摘要。
func (s *smtpSession) recordDetail(part string) {
s.detailParts = append(s.detailParts, part)
}
// Auth authenticates the user with SASL PLAIN credentials.
func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
if mech != sasl.Plain {
s.recordFail("不支持的认证机制")
return nil, smtp.ErrAuthUnknownMechanism
}
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已被封禁")
return smtp.ErrAuthFailed
}
user, err := s.backend.server.stores.Users.AuthenticateLogin(username, password)
if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries
s.backend.server.stores.RecordAuthFailure(
s.clientIP,
s.backend.server.banCfg.MaxFailAttempts,
s.backend.server.banCfg.BanDurationMin,
"邮件协议认证失败次数过多",
)
s.recordFail("用户名或密码错误")
return smtp.ErrAuthFailed
}
// 登录成功清零失败计数(与 Web 登录一致):防止合法用户 IP
// 因失败计数只增不减被反复误封。
s.backend.server.stores.Bans.ResetFail(s.clientIP)
domainName := user.Domain.Name
if domainName == "" {
domain, err := s.backend.server.stores.Domains.GetByID(user.DomainID)
if err == nil {
domainName = domain.Name
}
}
if domainName == "" {
s.recordFail("用户名或密码错误")
return smtp.ErrAuthFailed
}
s.authenticated = true
s.authOK = true
s.userID = user.ID
s.user = user
s.email = user.Username + "@" + domainName
if s.conn != nil {
s.conn.SetUser(s.email)
}
return nil
}), nil
}
// Mail records the sender address (MAIL FROM command).
func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
if s.mode != smtpModeInbound && !s.authenticated {
s.recordFail("未认证用户尝试发信")
return smtp.ErrAuthRequired
}
// Authenticated users may only send as themselves, preventing spoofing.
if s.authenticated && !strings.EqualFold(strings.TrimSpace(from), s.email) {
s.recordFail("发件人地址与登录用户不一致")
return fmt.Errorf("sender address must match authenticated user")
}
s.from = from
s.rcpts = s.rcpts[:0]
s.localRcpts = s.localRcpts[:0]
s.externalRcpts = s.externalRcpts[:0]
return nil
}
// Rcpt validates and records a recipient address (RCPT TO command).
// Local recipients are delivered to the mailbox; external recipients are
// allowed only for authenticated users and go to the outbound queue,
// which prevents open relay.
func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error {
to = strings.TrimSpace(to)
if to == "" {
s.recordFail("无效的收件人地址")
return fmt.Errorf("invalid recipient address: %s", to)
}
if _, err := s.localUserByEmail(to); err == nil {
s.rcpts = append(s.rcpts, to)
s.localRcpts = append(s.localRcpts, to)
return nil
}
// External recipient: only authenticated local users may relay.
if !s.authenticated {
s.recordFail("中继访问被拒绝")
return fmt.Errorf("relay access denied: %s", to)
}
// Sender verification must have been enforced in Mail() already.
ob := s.backend.server.outbound
if ob == nil || !ob.Enabled() {
s.recordFail("外部投递未启用")
return fmt.Errorf("external delivery is disabled: %s", to)
}
s.rcpts = append(s.rcpts, to)
s.externalRcpts = append(s.externalRcpts, to)
return nil
}
func (s *smtpSession) localUserByEmail(email string) (*db.User, error) {
return s.backend.server.stores.Users.GetByEmail(strings.TrimSpace(email))
}
// Data handles the message body and stores it for local recipients.
// 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")
}
data, err := io.ReadAll(r)
if err != nil {
s.recordFail("读取邮件数据失败")
return fmt.Errorf("failed to read message data: %w", err)
}
parsed, err := parseSMTPMessage(data)
if err != nil {
s.recordFail("邮件格式解析失败")
return err
}
// Local recipients: deliver to INBOX.
localDelivered := 0
for _, rcpt := range s.localRcpts {
user, err := s.localUserByEmail(rcpt)
if err != nil {
log.Printf("SMTP: recipient not found %s, skipping", rcpt)
continue
}
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 pusher := s.backend.server.pusher; pusher != nil && msg != nil {
pusher.PushNewMessage(user.Username+"@"+user.Domain.Name, msg)
}
}
s.msgCount += localDelivered
// External recipients: queue for outbound delivery.
externalQueued := 0
if len(s.externalRcpts) > 0 {
ob := s.backend.server.outbound
if ob == nil {
s.recordFail("外部投递服务不可用")
return fmt.Errorf("outbound delivery is unavailable")
}
maxRcpt := ob.MaxRecipients()
if maxRcpt > 0 && len(s.externalRcpts) > maxRcpt {
s.recordFail("外部收件人数量超出限制")
return fmt.Errorf("too many external recipients: %d (max %d)", len(s.externalRcpts), maxRcpt)
}
for _, rcpt := range s.externalRcpts {
if _, err := ob.Enqueue(s.user, s.email, rcpt, data); err != nil {
s.recordFail("外发队列投递失败")
return fmt.Errorf("failed to queue external recipient %s: %v", rcpt, err)
}
log.Printf("SMTP: external message queued for %s", rcpt)
externalQueued++
}
}
s.msgCount += externalQueued
if s.authenticated && s.userID != 0 && s.mode != smtpModeInbound {
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)
}
}
s.recordDetail(fmt.Sprintf("MAIL FROM:<%s> RCPT×%d 本地投递%d 外发%d",
s.from, len(s.rcpts), localDelivered, externalQueued))
return nil
}
type parsedSMTPMessage struct {
messageID string
fromAddr string
toAddr string
ccAddr string
subject string
textBody string
htmlBody string
date time.Time
attachments []*parsedAttachment
}
// parsedAttachment holds an extracted MIME attachment part.
type parsedAttachment struct {
fileName string
contentType string
data []byte
}
func parseSMTPMessage(data []byte) (*parsedSMTPMessage, error) {
mr, err := mail.CreateReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("failed to parse MIME message: %w", err)
}
header := mr.Header
msg := &parsedSMTPMessage{}
msg.fromAddr = mailutil.FormatAddressList(&header, "From")
msg.toAddr = mailutil.FormatAddressList(&header, "To")
msg.ccAddr = mailutil.FormatAddressList(&header, "Cc")
msg.subject, _ = header.Subject()
msg.messageID, _ = header.MessageID()
msg.date, _ = header.Date()
if msg.date.IsZero() {
msg.date = time.Now()
}
for {
p, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
log.Printf("SMTP: error reading MIME part: %v", err)
break
}
switch h := p.Header.(type) {
case *mail.InlineHeader:
contentType, params, _ := h.ContentType()
buf, readErr := io.ReadAll(p.Body)
if readErr != nil {
log.Printf("SMTP: error reading inline part: %v", readErr)
continue
}
charset := ""
if cs, ok := params["charset"]; ok {
charset = cs
}
decoded := mailutil.DecodeCharset(buf, charset)
if strings.HasPrefix(contentType, "text/plain") {
msg.textBody = decoded
} else if strings.HasPrefix(contentType, "text/html") {
msg.htmlBody = decoded
}
case *mail.AttachmentHeader:
filename, _ := h.Filename()
if filename == "" {
filename = "unnamed_attachment"
}
contentType, _, _ := h.ContentType()
buf, readErr := io.ReadAll(p.Body)
if readErr != nil {
log.Printf("SMTP: error reading attachment part: %v", readErr)
continue
}
msg.attachments = append(msg.attachments, &parsedAttachment{
fileName: filename,
contentType: contentType,
data: buf,
})
}
}
if msg.textBody == "" && msg.htmlBody == "" {
msg.textBody = string(data)
}
return msg, nil
}
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,
Folder: folder,
FromAddr: parsed.fromAddr,
ToAddr: parsed.toAddr,
CcAddr: parsed.ccAddr,
Subject: parsed.subject,
TextBody: parsed.textBody,
HtmlBody: parsed.htmlBody,
RawData: string(data),
IsRead: read,
IsFlagged: false,
Date: parsed.date,
}
if err := s.backend.server.stores.Mails.Create(msg); err != nil {
return nil, err
}
// Persist attachments to disk and link them to the message so that the
// Web mail UI can list/download them and quota accounting stays correct.
for _, att := range parsed.attachments {
relPath, err := s.backend.server.storage.Save(att.fileName, att.data)
if err != nil {
log.Printf("SMTP: failed to save attachment %s: %v", att.fileName, err)
continue
}
rec := &db.Attachment{
MessageID: msg.ID,
FileName: att.fileName,
FilePath: relPath,
ContentType: att.contentType,
FileSize: int64(len(att.data)),
}
if err := s.backend.server.stores.Attachments.Create(rec); err != nil {
log.Printf("SMTP: failed to create attachment record: %v", err)
continue
}
_ = s.backend.server.stores.Users.UpdateUsedBytes(userID, rec.FileSize)
}
return msg, nil
}
// Reset clears the session state for the next message on the same connection.
func (s *smtpSession) Reset() {
s.from = ""
s.rcpts = s.rcpts[:0]
s.localRcpts = s.localRcpts[:0]
s.externalRcpts = s.externalRcpts[:0]
}
// 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
}
// writeProtocolLog 汇总本会话状态写入协议调用日志(供后台分析攻击/滥用)。
func (s *smtpSession) writeProtocolLog() {
success := s.failReason == ""
detail := strings.Join(s.detailParts, "; ")
username := s.authUsername
if username == "" && s.email != "" {
username = s.email
}
if detail == "" {
if s.authTried {
if s.authOK {
detail = "AUTH 成功"
} else {
detail = "AUTH 失败"
}
} else if success {
detail = "连接建立,无邮件操作"
}
}
if success && s.authTried && !s.authOK {
success = false
}
entry := &db.ProtocolLog{
Protocol: db.ProtocolSMTP,
Port: s.port,
ClientIP: s.clientIP,
Username: username,
Success: success,
FailReason: s.failReason,
Detail: detail,
MsgCount: s.msgCount,
DurationMs: time.Since(s.startedAt).Milliseconds(),
CreatedAt: time.Now(),
}
if err := s.backend.server.stores.ProtocolLogs.Create(entry); err != nil {
log.Printf("SMTP: 写入协议日志失败: %v", err)
}
}