改进邮件转发的投递兼容性 (#347)

优先使用原始邮件数据转发,保留原始发件人、主题、正文、MIME 和 DKIM 签名,避免重建邮件导致严格 DMARC 收件方拒收。

当原始转发被拒时,回退为使用本地转发账号重新投递,同时保持原始主题不变。

同时修正本地域名转发逻辑:精确识别本地域名,自转发时报错,转发给其他本地用户时直接投递到本地收件箱,避免旧的字符串包含判断误拦截。

测试:go test -v ./services/rule ./dto/parsemail ./utils/send

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cd1s 2026-07-02 22:29:39 +07:00 committed by GitHub
parent 6455635f74
commit 05bca5d5a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 293 additions and 53 deletions

View File

@ -54,23 +54,23 @@ type Attachment struct {
// 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
}
// GenerateMsgID creates an RFC-compliant Message-ID unique enough to avoid spam filters.
@ -433,40 +433,31 @@ func buildUsers(strs []string) []*User {
return ret
}
func (e *Email) ForwardBuildBytes(ctx *context.Context, sender *models.User) []byte {
func (e *Email) ForwardBuildBytes(ctx *context.Context, sender *models.User, forwardAddress string) []byte {
var b bytes.Buffer
from := []*mail.Address{{e.From.Name, e.From.EmailAddress}}
to := []*mail.Address{}
for _, user := range e.To {
to = append(to, &mail.Address{
Name: user.Name,
Address: user.EmailAddress,
})
}
forwardUser := buildUser(forwardAddress)
to := []*mail.Address{{forwardUser.Name, forwardUser.EmailAddress}}
senderAddress := []*mail.Address{{sender.Name, fmt.Sprintf("%s@%s", sender.Account, config.Instance.Domains[0])}}
senderEmailAddress := fmt.Sprintf("%s@%s", sender.Account, config.Instance.Domains[0])
senderAddress := []*mail.Address{{sender.Name, senderEmailAddress}}
// Create our mail header
var h mail.Header
h.SetDate(time.Now())
h.SetAddressList("From", from)
h.SetAddressList("From", senderAddress)
h.SetAddressList("Sender", senderAddress)
h.SetAddressList("To", to)
h.SetText("Subject", e.Subject)
if e.MsgID != "" {
h.SetMessageID(e.MsgID)
} else {
h.SetMessageID(fmt.Sprintf("%d@%s", e.MessageId, config.Instance.Domain))
if e.From != nil && e.From.EmailAddress != "" {
h.SetAddressList("Reply-To", []*mail.Address{{e.From.Name, e.From.EmailAddress}})
h.Set("X-Original-From", e.From.Build())
}
if len(e.Cc) != 0 {
cc := []*mail.Address{}
for _, user := range e.Cc {
cc = append(cc, &mail.Address{
Name: user.Name,
Address: user.EmailAddress,
})
}
h.SetAddressList("Cc", cc)
h.Set("X-Forwarded-By", senderEmailAddress)
h.Set("X-Forwarded-To", forwardUser.EmailAddress)
h.SetText("Subject", e.Subject)
h.SetMessageID(GenerateMsgID(config.Instance.Domain))
if e.MsgID != "" {
h.Set("References", fmt.Sprintf("<%s>", e.MsgID))
h.Set("In-Reply-To", fmt.Sprintf("<%s>", e.MsgID))
}
// Create a new mail writer
@ -516,6 +507,10 @@ func (e *Email) ForwardBuildBytes(ctx *context.Context, sender *models.User) []b
mw.Close()
if instance == nil {
return b.Bytes()
}
// dkim 签名后返回
return instance.Sign(b.String())
}

View File

@ -0,0 +1,77 @@
package parsemail
import (
"bytes"
"strings"
"testing"
"github.com/Jinnrry/pmail/config"
"github.com/Jinnrry/pmail/models"
"github.com/Jinnrry/pmail/utils/context"
"github.com/emersion/go-message/mail"
)
func TestForwardBuildBytesRemailsFromLocalUser(t *testing.T) {
oldConfig := config.Instance
oldDkim := instance
defer func() {
config.Instance = oldConfig
instance = oldDkim
}()
config.Instance = &config.Config{Domain: "example.com", Domains: []string{"example.com"}}
instance = nil
email := &Email{
From: &User{Name: "Original Sender", EmailAddress: "sender@example.com"},
To: []*User{{EmailAddress: "forwarder@example.com"}},
Subject: "hello",
Text: []byte("forward body"),
MsgID: "original-message@example.com",
}
data := email.ForwardBuildBytes(&context.Context{}, &models.User{Name: "forwarder", Account: "forwarder"}, "recipient@example.net")
msg, err := mail.CreateReader(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
defer msg.Close()
from, err := msg.Header.AddressList("From")
if err != nil {
t.Fatal(err)
}
if len(from) != 1 || from[0].Address != "forwarder@example.com" {
t.Fatalf("expected forwarded From to be local user, got %+v", from)
}
to, err := msg.Header.AddressList("To")
if err != nil {
t.Fatal(err)
}
if len(to) != 1 || to[0].Address != "recipient@example.net" {
t.Fatalf("expected forwarded To to be target address, got %+v", to)
}
replyTo, err := msg.Header.AddressList("Reply-To")
if err != nil {
t.Fatal(err)
}
if len(replyTo) != 1 || replyTo[0].Address != "sender@example.com" {
t.Fatalf("expected Reply-To to preserve original sender, got %+v", replyTo)
}
if got := msg.Header.Get("X-Original-From"); !strings.Contains(got, "sender@example.com") {
t.Fatalf("expected X-Original-From to include original sender, got %q", got)
}
if got := msg.Header.Get("X-Forwarded-By"); got != "forwarder@example.com" {
t.Fatalf("expected X-Forwarded-By to be local user, got %q", got)
}
if got := msg.Header.Get("X-Forwarded-To"); got != "recipient@example.net" {
t.Fatalf("expected X-Forwarded-To to be target, got %q", got)
}
if got := msg.Header.Get("Subject"); got != "hello" {
t.Fatalf("expected original subject, got %q", got)
}
}

View File

@ -171,7 +171,7 @@ func (s *Session) Data(r io.Reader) error {
rs := rule.GetAllRules(ctx, user.ID)
for _, r := range rs {
if rule.MatchRule(ctx, r, email) {
rule.DoRule(ctx, r, email, user)
rule.DoRule(ctx, r, email, user, emailData)
}
}
}

View File

@ -1,6 +1,9 @@
package rule
import (
"fmt"
"strings"
"github.com/Jinnrry/pmail/config"
"github.com/Jinnrry/pmail/consts"
"github.com/Jinnrry/pmail/db"
@ -12,7 +15,6 @@ import (
"github.com/Jinnrry/pmail/utils/send"
log "github.com/sirupsen/logrus"
"github.com/spf13/cast"
"strings"
)
func GetAllRules(ctx *context.Context, userId int) []*dto.Rule {
@ -60,7 +62,7 @@ func MatchRule(ctx *context.Context, rule *dto.Rule, email *parsemail.Email) boo
return true
}
func DoRule(ctx *context.Context, rule *dto.Rule, email *parsemail.Email, user *models.User) {
func DoRule(ctx *context.Context, rule *dto.Rule, email *parsemail.Email, user *models.User, rawEmailData []byte) {
log.WithContext(ctx).Debugf("执行规则:%s", rule.Name)
switch rule.Action {
@ -77,13 +79,11 @@ func DoRule(ctx *context.Context, rule *dto.Rule, email *parsemail.Email, user *
log.WithContext(ctx).Errorf("sqlERror :%v", err)
}
case dto.FORWARD:
if strings.Contains(rule.Params, config.Instance.Domain) {
log.WithContext(ctx).Errorf("Forward Error! loop forwarding!")
return
}
err := send.Forward(ctx, email, rule.Params, user)
err := doForward(ctx, email, rule.Params, user, rawEmailData)
if err != nil {
log.WithContext(ctx).Errorf("Forward Error:%v", err)
} else {
log.WithContext(ctx).Infof("Forward Success:%s@%s -> %s", user.Account, config.Instance.Domains[0], rule.Params)
}
case dto.MOVE:
doMove(ctx, rule, email, user)
@ -91,6 +91,78 @@ func DoRule(ctx *context.Context, rule *dto.Rule, email *parsemail.Email, user *
}
func doForward(ctx *context.Context, email *parsemail.Email, forwardAddress string, user *models.User, rawEmailData []byte) error {
forwardUser := parsemail.BuilderUser(forwardAddress)
if forwardUser == nil || forwardUser.EmailAddress == "" {
return fmt.Errorf("invalid forward address: %s", forwardAddress)
}
account, domain := forwardUser.GetDomainAccount()
if account == "" || domain == "" {
return fmt.Errorf("invalid forward address: %s", forwardAddress)
}
if isLocalDomain(domain) {
if strings.EqualFold(account, user.Account) {
return fmt.Errorf("loop forwarding to self: %s", forwardAddress)
}
return forwardToLocalUser(ctx, email, account, forwardAddress)
}
if len(rawEmailData) > 0 {
err := send.ForwardRaw(ctx, email, rawEmailData, forwardAddress, user)
if err == nil {
return nil
}
log.WithContext(ctx).Warnf("Raw forward failed, retry remail forward:%v", err)
}
return send.Forward(ctx, email, forwardAddress, user)
}
func forwardToLocalUser(ctx *context.Context, email *parsemail.Email, account, forwardAddress string) error {
if email.MessageId <= 0 {
return fmt.Errorf("email has not been saved before local forwarding")
}
var user models.User
has, err := db.Instance.Table(&models.User{}).
Where("LOWER(account)=LOWER(?) and disabled=0", account).
Get(&user)
if err != nil {
return err
}
if !has {
return fmt.Errorf("local forward user not found: %s", forwardAddress)
}
var ue models.UserEmail
has, err = db.Instance.Table(&models.UserEmail{}).
Where("email_id=? and user_id=?", email.MessageId, user.ID).
Get(&ue)
if err != nil {
return err
}
if has {
return nil
}
_, err = db.Instance.Insert(&models.UserEmail{
UserID: user.ID,
EmailID: cast.ToInt(email.MessageId),
Status: cast.ToInt8(email.Status),
})
return err
}
func isLocalDomain(domain string) bool {
for _, localDomain := range config.Instance.Domains {
if strings.EqualFold(domain, localDomain) {
return true
}
}
return strings.EqualFold(domain, config.Instance.Domain)
}
func doMove(ctx *context.Context, rule *dto.Rule, email *parsemail.Email, user *models.User) {
groupId := cast.ToInt(rule.Params)

View File

@ -0,0 +1,85 @@
package rule
import (
"strings"
"testing"
"github.com/Jinnrry/pmail/config"
"github.com/Jinnrry/pmail/db"
"github.com/Jinnrry/pmail/dto/parsemail"
"github.com/Jinnrry/pmail/models"
"github.com/Jinnrry/pmail/utils/context"
_ "modernc.org/sqlite"
"xorm.io/xorm"
)
func TestForwardToLocalUser(t *testing.T) {
oldConfig := config.Instance
oldDB := db.Instance
defer func() {
config.Instance = oldConfig
db.Instance = oldDB
}()
config.Instance = &config.Config{
Domain: "example.com",
Domains: []string{"example.com"},
}
engine, err := xorm.NewEngine("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer engine.Close()
db.Instance = engine
if err := engine.Sync2(&models.User{}, &models.UserEmail{}); err != nil {
t.Fatal(err)
}
sender := &models.User{ID: 1, Account: "sender", Name: "sender"}
recipient := &models.User{ID: 2, Account: "recipient", Name: "recipient", IsAdmin: 1}
if _, err := engine.Insert(sender, recipient); err != nil {
t.Fatal(err)
}
email := &parsemail.Email{MessageId: 123, Status: 0}
if err := doForward(&context.Context{}, email, "recipient@example.com", sender, nil); err != nil {
t.Fatal(err)
}
var links []models.UserEmail
if err := engine.Table(&models.UserEmail{}).Where("email_id=?", email.MessageId).Find(&links); err != nil {
t.Fatal(err)
}
if len(links) != 1 || links[0].UserID != recipient.ID {
t.Fatalf("expected one user_email link for recipient, got %+v", links)
}
if err := doForward(&context.Context{}, email, "recipient@example.com", sender, nil); err != nil {
t.Fatal(err)
}
var count int64
count, err = engine.Table(&models.UserEmail{}).Where("email_id=? and user_id=?", email.MessageId, recipient.ID).Count()
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("expected idempotent local forward link, got %d links", count)
}
}
func TestForwardToLocalUserRejectsSelfLoop(t *testing.T) {
oldConfig := config.Instance
defer func() { config.Instance = oldConfig }()
config.Instance = &config.Config{
Domain: "example.com",
Domains: []string{"example.com"},
}
err := doForward(&context.Context{}, &parsemail.Email{MessageId: 1}, "sender@example.com", &models.User{Account: "sender"}, nil)
if err == nil || !strings.Contains(err.Error(), "loop forwarding to self") {
t.Fatalf("expected self-loop error, got %v", err)
}
}

View File

@ -28,17 +28,28 @@ func Forward(ctx *context.Context, e *parsemail.Email, forwardAddress string, us
log.WithContext(ctx).Debugf("开始转发邮件")
b := e.ForwardBuildBytes(ctx, user)
b := e.ForwardBuildBytes(ctx, user, forwardAddress)
log.WithContext(ctx).Debugf("%s", b)
from := user.Account + "@" + config.Instance.Domains[0]
return forwardData(ctx, config.Instance.Domains[0], b, forwardAddress, from)
}
func ForwardRaw(ctx *context.Context, e *parsemail.Email, rawEmailData []byte, forwardAddress string, user *models.User) error {
log.WithContext(ctx).Debugf("开始原始邮件转发")
from := user.Account + "@" + config.Instance.Domains[0]
return forwardData(ctx, config.Instance.Domains[0], rawEmailData, forwardAddress, from)
}
func forwardData(ctx *context.Context, fromDomain string, data []byte, forwardAddress string, from string) error {
var to []*parsemail.User
to = []*parsemail.User{
{EmailAddress: forwardAddress},
}
err, _ := doSend(ctx, config.Instance.Domains[0], b, to, e.From.EmailAddress)
err, _ := doSend(ctx, fromDomain, data, to, from)
return err
}