Files
mailgo/internal/smtp_server/server.go
T
kevin 353bfa88f2 feat: 新增 SMTP/IMAP/POP3 协议调用日志(含攻击分析筛选)
- 每个连接记录一条日志:协议、端口、来源 IP、用户名、成功/失败、
  失败原因(密码错误/IP封禁/中继被拒/发件人伪造/未认证发信等)、
  操作摘要、消息数与会话时长
- 管理后台新增「协议日志」页:按协议/状态/IP/用户名/时间筛选,
  今日与历史成功/失败统计卡片,分页查看,可手动清理
- 后台每 6 小时自动清理超出 protocol_log_keep_days(默认30天)
  的日志;新增 [web] protocol_log_keep_days 配置项
- 修复 POP3 认证既有 bug:handleUSER 丢弃邮箱域名导致 PASS 永远失败
- 新增 store 单测、SMTP/POP3 端到端测试与模板渲染测试
2026-08-19 18:49:29 +08:00

552 lines
15 KiB
Go
Raw 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/db"
"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
}
// NewSMTPServer creates a new SMTP server instance. tlsLoader may be nil
// when TLS is not configured.
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, tlsLoader *tlsutil.Loader, banCfg config.BanConfig) *SMTPServer {
return &SMTPServer{stores: stores, storage: attStorage, outbound: ob, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader}
}
func (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) {
return &smtpSession{
backend: be,
mode: be.mode,
rcpts: make([]string, 0),
clientIP: store.ClientIPFromAddr(c.Conn().RemoteAddr()),
startedAt: time.Now(),
port: be.server.sessionPort(be.mode),
}, nil
}
// 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
}
// 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
// 已封禁 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.Authenticate(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
}
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
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 {
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
}
if err := s.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil {
log.Printf("SMTP: failed to create message for %s: %v", rcpt, err)
continue
}
log.Printf("SMTP: message delivered to %s", rcpt)
localDelivered++
}
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) 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 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 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()
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)
}
}