feat(email): 新增 SPF/DKIM 邮件认证追踪 (#354)

新增 EmailAuthentication 统一封装 SPF/DKIM 验证结果与 Dangerous 结论。

重构邮件列表与 SMTP 收信流程,保证认证结果在插件、数据库和列表展示之间一致,并保留伪造邮件处理优先级。

扩展解析邮件 DTO 的认证字段,覆盖历史收件恢复、发送路径隔离、HookDTO 序列化及旧版插件兼容性测试。
This commit is contained in:
P家单推人 2026-08-13 14:24:46 +08:00 committed by GitHub
parent 593c3fb720
commit 430c9c6c10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 338 additions and 24 deletions

View File

@ -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,
})
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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