From 430c9c6c1092fe236323167445a0152f6f93a1d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=E5=AE=B6=E5=8D=95=E6=8E=A8=E4=BA=BA?= Date: Thu, 13 Aug 2026 14:24:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(email):=20=E6=96=B0=E5=A2=9E=20SPF/DKIM=20?= =?UTF-8?q?=E9=82=AE=E4=BB=B6=E8=AE=A4=E8=AF=81=E8=BF=BD=E8=B8=AA=20(#354)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 EmailAuthentication 统一封装 SPF/DKIM 验证结果与 Dangerous 结论。 重构邮件列表与 SMTP 收信流程,保证认证结果在插件、数据库和列表展示之间一致,并保留伪造邮件处理优先级。 扩展解析邮件 DTO 的认证字段,覆盖历史收件恢复、发送路径隔离、HookDTO 序列化及旧版插件兼容性测试。 --- server/controllers/email/list.go | 4 +- server/dto/parsemail/email.go | 57 ++++--- .../parsemail/email_authentication_test.go | 63 ++++++++ server/hooks/framework/framework_test.go | 66 ++++++++ .../smtp_server/email_authentication_test.go | 152 ++++++++++++++++++ server/listen/smtp_server/read_content.go | 20 ++- 6 files changed, 338 insertions(+), 24 deletions(-) create mode 100644 server/dto/parsemail/email_authentication_test.go create mode 100644 server/hooks/framework/framework_test.go create mode 100644 server/listen/smtp_server/email_authentication_test.go diff --git a/server/controllers/email/list.go b/server/controllers/email/list.go index 4f49af62..d30e7d68 100644 --- a/server/controllers/email/list.go +++ b/server/controllers/email/list.go @@ -3,6 +3,7 @@ package email import ( "encoding/json" "github.com/Jinnrry/pmail/dto" + "github.com/Jinnrry/pmail/dto/parsemail" "github.com/Jinnrry/pmail/dto/response" "github.com/Jinnrry/pmail/services/list" "github.com/Jinnrry/pmail/utils/context" @@ -84,6 +85,7 @@ func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { var tos []User _ = json.Unmarshal([]byte(email.To), &tos) + authentication := parsemail.NewEmailAuthentication(email.SPFCheck == 1, email.DKIMCheck == 1) lst = append(lst, &emilItem{ ID: email.Id, @@ -93,7 +95,7 @@ func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { IsRead: email.IsRead == 1, Sender: sender, To: tos, - Dangerous: email.SPFCheck == 0 && email.DKIMCheck == 0, + Dangerous: authentication.Dangerous, Error: email.Error.String, }) } diff --git a/server/dto/parsemail/email.go b/server/dto/parsemail/email.go index ff9ce0c6..5ac4c85a 100644 --- a/server/dto/parsemail/email.go +++ b/server/dto/parsemail/email.go @@ -52,25 +52,42 @@ type Attachment struct { ContentID string } +// EmailAuthentication 表示收信时的发件人身份认证结果。 +type EmailAuthentication struct { + SPFPassed bool + DKIMPassed bool + Dangerous bool +} + +// NewEmailAuthentication 根据 SPF 和 DKIM 结果创建统一的邮件认证结论。 +func NewEmailAuthentication(spfPassed, dkimPassed bool) *EmailAuthentication { + return &EmailAuthentication{ + SPFPassed: spfPassed, + DKIMPassed: dkimPassed, + Dangerous: !spfPassed && !dkimPassed, + } +} + // Email is the type used for email messages type Email struct { - ReplyTo []*User - From *User - To []*User - Bcc []*User - Cc []*User - Subject string - Text []byte // Plaintext message (optional) - HTML []byte // Html message (optional) - Sender *User // override From as SMTP envelope sender (optional) - Headers textproto.MIMEHeader - Attachments []*Attachment - ReadReceipt []string - Date string - Status int // 0未发送,1已发送,2发送失败,3删除,5广告邮件 - MessageId int64 - MsgID string // RFC-compliant Message-ID, persisted in DB - Size int + ReplyTo []*User + From *User + To []*User + Bcc []*User + Cc []*User + Subject string + Text []byte // Plaintext message (optional) + HTML []byte // Html message (optional) + Sender *User // override From as SMTP envelope sender (optional) + Headers textproto.MIMEHeader + Attachments []*Attachment + ReadReceipt []string + Date string + Status int // 0未发送,1已发送,2发送失败,3删除,5广告邮件 + MessageId int64 + MsgID string // RFC-compliant Message-ID, persisted in DB + Size int + Authentication *EmailAuthentication } // GenerateMsgID creates an RFC-compliant Message-ID unique enough to avoid spam filters. @@ -201,7 +218,7 @@ func NewEmailFromModel(d models.Email) *Email { var Attachments []*Attachment json.Unmarshal([]byte(d.Attachments), &Attachments) - return &Email{ + ret := &Email{ MessageId: cast.ToInt64(d.Id), MsgID: d.MsgID, From: &User{ @@ -219,6 +236,10 @@ func NewEmailFromModel(d models.Email) *Email { Attachments: Attachments, Date: d.SendDate.Format("2006-01-02 15:04:05"), } + if d.Type == 0 { + ret.Authentication = NewEmailAuthentication(d.SPFCheck == 1, d.DKIMCheck == 1) + } + return ret } func NewEmailFromReader(to []string, r io.Reader, size int) *Email { diff --git a/server/dto/parsemail/email_authentication_test.go b/server/dto/parsemail/email_authentication_test.go new file mode 100644 index 00000000..57626fba --- /dev/null +++ b/server/dto/parsemail/email_authentication_test.go @@ -0,0 +1,63 @@ +package parsemail + +import ( + "testing" + + "github.com/Jinnrry/pmail/models" +) + +func TestNewEmailAuthentication(t *testing.T) { + tests := []struct { + name string + spfPassed bool + dkimPassed bool + dangerous bool + }{ + {name: "SPF和DKIM均通过", spfPassed: true, dkimPassed: true}, + {name: "仅SPF通过", spfPassed: true, dkimPassed: false}, + {name: "仅DKIM通过", spfPassed: false, dkimPassed: true}, + {name: "SPF和DKIM均未通过", spfPassed: false, dkimPassed: false, dangerous: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authentication := NewEmailAuthentication(tt.spfPassed, tt.dkimPassed) + if authentication.SPFPassed != tt.spfPassed { + t.Fatalf("SPFPassed = %v,期望 %v", authentication.SPFPassed, tt.spfPassed) + } + if authentication.DKIMPassed != tt.dkimPassed { + t.Fatalf("DKIMPassed = %v,期望 %v", authentication.DKIMPassed, tt.dkimPassed) + } + if authentication.Dangerous != tt.dangerous { + t.Fatalf("Dangerous = %v,期望 %v", authentication.Dangerous, tt.dangerous) + } + }) + } +} + +func TestNewEmailFromModelRestoresAuthenticationForReceivedEmail(t *testing.T) { + email := NewEmailFromModel(models.Email{ + Type: 0, + SPFCheck: 1, + DKIMCheck: 0, + }) + + if email.Authentication == nil { + t.Fatal("收件邮件缺少认证结果") + } + if !email.Authentication.SPFPassed || email.Authentication.DKIMPassed || email.Authentication.Dangerous { + t.Fatalf("认证结果不正确:%+v", email.Authentication) + } +} + +func TestNewEmailFromModelLeavesAuthenticationNilForSentEmail(t *testing.T) { + email := NewEmailFromModel(models.Email{ + Type: 1, + SPFCheck: 1, + DKIMCheck: 1, + }) + + if email.Authentication != nil { + t.Fatalf("发件邮件不应包含收信认证结果:%+v", email.Authentication) + } +} diff --git a/server/hooks/framework/framework_test.go b/server/hooks/framework/framework_test.go new file mode 100644 index 00000000..7bce0387 --- /dev/null +++ b/server/hooks/framework/framework_test.go @@ -0,0 +1,66 @@ +package framework + +import ( + "encoding/json" + "testing" + + "github.com/Jinnrry/pmail/dto/parsemail" +) + +func TestHookDTOIncludesEmailAuthentication(t *testing.T) { + dto := HookDTO{ + Email: &parsemail.Email{ + Subject: "认证结果测试", + Authentication: parsemail.NewEmailAuthentication(false, false), + }, + } + + body, err := json.Marshal(dto) + if err != nil { + t.Fatalf("序列化 HookDTO 失败:%v", err) + } + + var decoded struct { + Email *struct { + Authentication *parsemail.EmailAuthentication + } + } + if err = json.Unmarshal(body, &decoded); err != nil { + t.Fatalf("反序列化 HookDTO 失败:%v", err) + } + if decoded.Email == nil { + t.Fatal("HookDTO 中缺少邮件") + } + authentication := decoded.Email.Authentication + if authentication == nil { + t.Fatal("HookDTO 中缺少邮件认证结果") + } + if authentication.SPFPassed || authentication.DKIMPassed || !authentication.Dangerous { + t.Fatalf("HookDTO 中的认证结果不正确:%+v", authentication) + } +} + +func TestOlderPluginEmailShapeIgnoresAuthentication(t *testing.T) { + dto := HookDTO{ + Email: &parsemail.Email{ + Subject: "兼容性测试", + Authentication: parsemail.NewEmailAuthentication(true, false), + }, + } + body, err := json.Marshal(dto) + if err != nil { + t.Fatalf("序列化 HookDTO 失败:%v", err) + } + + var oldDTO struct { + Email *struct { + Subject string + } + } + if err = json.Unmarshal(body, &oldDTO); err != nil { + t.Fatalf("旧插件结构无法读取新增字段后的 HookDTO:%v", err) + } + if oldDTO.Email == nil || oldDTO.Email.Subject != "兼容性测试" { + t.Fatalf("旧插件结构读取到的邮件不正确:%+v", oldDTO.Email) + } +} diff --git a/server/listen/smtp_server/email_authentication_test.go b/server/listen/smtp_server/email_authentication_test.go new file mode 100644 index 00000000..bd7ac26a --- /dev/null +++ b/server/listen/smtp_server/email_authentication_test.go @@ -0,0 +1,152 @@ +package smtp_server + +import ( + "bytes" + "net" + "net/netip" + "testing" + + "github.com/Jinnrry/pmail/config" + "github.com/Jinnrry/pmail/db" + "github.com/Jinnrry/pmail/dto/parsemail" + "github.com/Jinnrry/pmail/hooks" + "github.com/Jinnrry/pmail/hooks/framework" + "github.com/Jinnrry/pmail/models" + "github.com/Jinnrry/pmail/utils/context" + "xorm.io/xorm" +) + +type authenticationCaptureHook struct { + parseAfter *parsemail.EmailAuthentication + saveAfter *parsemail.EmailAuthentication +} + +func (h *authenticationCaptureHook) SendBefore(ctx *context.Context, email *parsemail.Email) {} + +func (h *authenticationCaptureHook) SendAfter(ctx *context.Context, email *parsemail.Email, err map[string]error) { +} + +func (h *authenticationCaptureHook) ReceiveParseBefore(ctx *context.Context, email *[]byte) {} + +func (h *authenticationCaptureHook) ReceiveParseAfter(ctx *context.Context, email *parsemail.Email) { + h.parseAfter = cloneAuthentication(email.Authentication) + // 主程序认证结果不能被插件修改后带入数据库和后续钩子。 + email.Authentication.Dangerous = false +} + +func (h *authenticationCaptureHook) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) { + h.saveAfter = cloneAuthentication(email.Authentication) +} + +func (h *authenticationCaptureHook) GetName(ctx *context.Context) string { + return "authentication_capture" +} + +func (h *authenticationCaptureHook) SettingsHtml(ctx *context.Context, url string, requestData string) string { + return "" +} + +func cloneAuthentication(authentication *parsemail.EmailAuthentication) *parsemail.EmailAuthentication { + if authentication == nil { + return nil + } + ret := *authentication + return &ret +} + +func TestReceiveHooksAndDatabaseShareAuthenticationResult(t *testing.T) { + engine := newAuthenticationTestEngine(t) + + oldDB := db.Instance + oldConfig := config.Instance + oldHooks := hooks.HookList + t.Cleanup(func() { + db.Instance = oldDB + config.Instance = oldConfig + hooks.HookList = oldHooks + }) + + db.Instance = engine + config.Instance = &config.Config{ + Domain: "test.domain", + Domains: []string{"test.domain"}, + SpamFilterLevel: 0, + } + recipient := &models.User{Account: "recipient", Name: "收件人"} + if _, err := engine.Insert(recipient); err != nil { + t.Fatalf("创建测试收件人失败:%v", err) + } + + capture := &authenticationCaptureHook{} + hooks.HookList = map[string]framework.EmailHook{"capture": capture} + + emailData := []byte("From: sender\r\nTo: recipient@test.domain\r\nSubject: authentication flow\r\n\r\nbody") + session := Session{ + RemoteAddress: net.TCPAddrFromAddrPort(netip.MustParseAddrPort("203.0.113.1:25")), + Ctx: &context.Context{}, + To: []string{"recipient@test.domain"}, + } + if err := session.Data(bytes.NewReader(emailData)); err != nil { + t.Fatalf("收信流程执行失败:%v", err) + } + + assertDangerousAuthentication(t, "ReceiveParseAfter", capture.parseAfter) + assertDangerousAuthentication(t, "ReceiveSaveAfter", capture.saveAfter) + + var saved models.Email + has, err := engine.Where("subject = ?", "authentication flow").Get(&saved) + if err != nil { + t.Fatalf("查询落库邮件失败:%v", err) + } + if !has { + t.Fatal("未找到落库邮件") + } + if saved.SPFCheck != 0 || saved.DKIMCheck != 0 { + t.Fatalf("落库认证结果不正确:SPF=%d DKIM=%d", saved.SPFCheck, saved.DKIMCheck) + } +} + +func TestSaveSentEmailIgnoresReceiveAuthentication(t *testing.T) { + engine := newAuthenticationTestEngine(t) + + oldDB := db.Instance + t.Cleanup(func() { db.Instance = oldDB }) + db.Instance = engine + + email := &parsemail.Email{ + From: &parsemail.User{EmailAddress: "sender@test.domain"}, + Subject: "sent authentication isolation", + Authentication: parsemail.NewEmailAuthentication(false, false), + } + _, saved, err := saveEmail(&context.Context{UserID: 1}, 0, email, 1, 1, nil, true, true) + if err != nil { + t.Fatalf("保存发件邮件失败:%v", err) + } + if saved.SPFCheck != 1 || saved.DKIMCheck != 1 { + t.Fatalf("发件邮件认证落库值被收信认证对象覆盖:SPF=%d DKIM=%d", saved.SPFCheck, saved.DKIMCheck) + } +} + +func newAuthenticationTestEngine(t *testing.T) *xorm.Engine { + t.Helper() + engine, err := xorm.NewEngine("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatalf("创建测试数据库失败:%v", err) + } + engine.SetMaxOpenConns(1) + if err = engine.Sync2(&models.User{}, &models.Email{}, &models.UserEmail{}, &models.Rule{}); err != nil { + t.Fatalf("初始化测试数据库失败:%v", err) + } + t.Cleanup(func() { _ = engine.Close() }) + return engine +} + +func assertDangerousAuthentication(t *testing.T, hookName string, authentication *parsemail.EmailAuthentication) { + t.Helper() + if authentication == nil { + t.Fatalf("%s 未收到认证结果", hookName) + } + if authentication.SPFPassed || authentication.DKIMPassed || !authentication.Dangerous { + t.Fatalf("%s 收到的认证结果不正确:%+v", hookName, authentication) + } +} diff --git a/server/listen/smtp_server/read_content.go b/server/listen/smtp_server/read_content.go index c9ab0aaf..844eeb7c 100644 --- a/server/listen/smtp_server/read_content.go +++ b/server/listen/smtp_server/read_content.go @@ -146,19 +146,25 @@ func (s *Session) Data(r io.Reader) error { SPFStatus = spfCheck(s.RemoteAddress.String(), email.Sender, email.Sender.EmailAddress) + _, formDomain := email.From.GetDomainAccount() + spoofed := array.InArray(formDomain, config.Instance.Domains) && SPFStatus == false + if spoofed { + dkimStatus = false + } + email.Authentication = parsemail.NewEmailAuthentication(SPFStatus, dkimStatus) + log.WithContext(ctx).Debugf("开始执行插件ReceiveParseAfter!") for _, hook := range hooks.HookList { if hook == nil { continue } + email.Authentication = parsemail.NewEmailAuthentication(SPFStatus, dkimStatus) hook.ReceiveParseAfter(ctx, email) } log.WithContext(ctx).Debugf("开始执行插件ReceiveParseAfter!End") - - _, formDomain := email.From.GetDomainAccount() - // 伪造邮件 - if array.InArray(formDomain, config.Instance.Domains) && SPFStatus == false { - dkimStatus = false + email.Authentication = parsemail.NewEmailAuthentication(SPFStatus, dkimStatus) + // 伪造邮件状态必须覆盖插件分类结果,保持原有处理优先级。 + if spoofed { email.Status = 3 } @@ -206,6 +212,10 @@ func (s *Session) Data(r io.Reader) error { } func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserID int, emailType int, reallyTo []string, SPFStatus, dkimStatus bool) ([]*models.User, *models.Email, error) { + if emailType == 0 && email != nil && email.Authentication != nil { + SPFStatus = email.Authentication.SPFPassed + dkimStatus = email.Authentication.DKIMPassed + } var dkimV, spfV int8 if dkimStatus { dkimV = 1