diff --git a/server/dto/parsemail/email.go b/server/dto/parsemail/email.go index 804fa79..ff9ce0c 100644 --- a/server/dto/parsemail/email.go +++ b/server/dto/parsemail/email.go @@ -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()) } diff --git a/server/dto/parsemail/email_forward_test.go b/server/dto/parsemail/email_forward_test.go new file mode 100644 index 0000000..4f1eff7 --- /dev/null +++ b/server/dto/parsemail/email_forward_test.go @@ -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) + } +} diff --git a/server/listen/smtp_server/read_content.go b/server/listen/smtp_server/read_content.go index e9c001c..c9ab0aa 100644 --- a/server/listen/smtp_server/read_content.go +++ b/server/listen/smtp_server/read_content.go @@ -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) } } } diff --git a/server/services/rule/rule.go b/server/services/rule/rule.go index 5ac2672..0873254 100644 --- a/server/services/rule/rule.go +++ b/server/services/rule/rule.go @@ -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) diff --git a/server/services/rule/rule_forward_test.go b/server/services/rule/rule_forward_test.go new file mode 100644 index 0000000..fff9fcf --- /dev/null +++ b/server/services/rule/rule_forward_test.go @@ -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) + } +} diff --git a/server/utils/send/send.go b/server/utils/send/send.go index c2709cf..54dab8b 100644 --- a/server/utils/send/send.go +++ b/server/utils/send/send.go @@ -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 }