feat(email): 新增 SPF/DKIM 邮件认证追踪 (#354)
新增 EmailAuthentication 统一封装 SPF/DKIM 验证结果与 Dangerous 结论。 重构邮件列表与 SMTP 收信流程,保证认证结果在插件、数据库和列表展示之间一致,并保留伪造邮件处理优先级。 扩展解析邮件 DTO 的认证字段,覆盖历史收件恢复、发送路径隔离、HookDTO 序列化及旧版插件兼容性测试。
This commit is contained in:
parent
593c3fb720
commit
430c9c6c10
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
63
server/dto/parsemail/email_authentication_test.go
Normal file
63
server/dto/parsemail/email_authentication_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
66
server/hooks/framework/framework_test.go
Normal file
66
server/hooks/framework/framework_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
152
server/listen/smtp_server/email_authentication_test.go
Normal file
152
server/listen/smtp_server/email_authentication_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user