改进邮件转发的投递兼容性 (#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:
parent
6455635f74
commit
05bca5d5a8
@ -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())
|
||||
}
|
||||
|
||||
77
server/dto/parsemail/email_forward_test.go
Normal file
77
server/dto/parsemail/email_forward_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
85
server/services/rule/rule_forward_test.go
Normal file
85
server/services/rule/rule_forward_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user