feat: add rule forwarding action

This commit is contained in:
zxyszx
2026-08-02 20:32:34 +08:00
parent aa87315e94
commit 72c96765ef
6 changed files with 195 additions and 56 deletions
+65
View File
@@ -955,6 +955,71 @@ func TestMailRulesConditionGroupsAndActions(t *testing.T) {
}
}
func TestMailRulesForwardingAction(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
a.cfg.SMTPHost = "127.0.0.1"
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d", code)
}
domainID := mustDefaultDomainID(t, a)
sender := createTestMailbox(t, admin, domainID, "rule-forward-sender", "Rule Forward Sender", "Password123!", nil)
recipient := createTestMailbox(t, admin, domainID, "netflix", "Netflix", "Password123!", nil)
now := a.now().UTC().Format(time.RFC3339Nano)
for _, email := range []string{"driver-a@example.test", "driver-b@example.test"} {
if _, err := a.db.ExecContext(context.Background(), `INSERT INTO forwarding_verified_emails(id,user_id,email,verified,verified_at,delivery_status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`,
newID("fwd"), recipient.UserID, email, 1, now, "verified", now, now); err != nil {
t.Fatal(err)
}
}
rcpt := &testClient{t: t, server: ts}
if code := rcpt.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("recipient login=%d", code)
}
var rule MailRule
rulePayload := map[string]any{
"mailboxId": recipient.ID,
"name": "Netflix 验证码转发",
"matchMode": "all",
"conditions": []map[string]string{{"field": "subject", "operator": "contains", "value": "Netflix"}},
"actions": []map[string]string{{"type": "forward", "value": "driver-a@example.test, driver-b@example.test"}},
}
if code := rcpt.do("POST", "/api/me/rules", rulePayload, &rule); code != http.StatusCreated {
t.Fatalf("create forwarding rule code=%d rule=%+v", code, rule)
}
if len(rule.Actions) != 1 || rule.Actions[0].Type != "forward" || !strings.Contains(rule.Actions[0].Value, "driver-a@example.test") || !strings.Contains(rule.Actions[0].Value, "driver-b@example.test") {
t.Fatalf("rule forwarding action not normalized: %+v", rule.Actions)
}
senderClient := &testClient{t: t, server: ts}
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("sender login=%d", code)
}
var sent MailMessage
if code := senderClient.do("POST", "/api/mail/send", map[string]any{
"to": []string{recipient.Address},
"subject": "Netflix 登录验证码",
"text": "验证码 123456",
}, &sent); code != http.StatusCreated {
t.Fatalf("send code=%d sent=%+v", code, sent)
}
var recipientsJSON, mailFrom string
if err := a.db.QueryRow(`SELECT recipients_json,mail_from FROM send_queue WHERE source=?`, sendSourceRuleForwarding).Scan(&recipientsJSON, &mailFrom); err != nil {
t.Fatal(err)
}
if mailFrom != recipient.Address || !strings.Contains(recipientsJSON, "driver-a@example.test") || !strings.Contains(recipientsJSON, "driver-b@example.test") {
t.Fatalf("rule forwarding mail_from=%q recipients=%s", mailFrom, recipientsJSON)
}
}
func TestMailRulesMailboxIsolation(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
@@ -3,9 +3,12 @@ package app
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"strings"
"unicode"
)
const forwardingHeaderName = "X-LanQin-Forwarded-By"
@@ -69,6 +72,62 @@ func (a *App) processInboundForwarding(ctx context.Context, messageID, mailboxID
}
}
func (a *App) processRuleForwarding(ctx context.Context, messageID, mailboxID string, action MailRuleAction) error {
var userID, mailboxAddress string
if err := a.db.QueryRowContext(ctx, `SELECT user_id,address FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan(&userID, &mailboxAddress); err != nil {
return err
}
targets, err := a.cleanForwardingTargets(ctx, userID, splitRuleForwardTargets(action.Value))
if err != nil {
return err
}
self := normalizeEmail(mailboxAddress)
filteredTargets := make([]string, 0, len(targets))
for _, target := range targets {
if normalizeEmail(target) == self {
continue
}
filteredTargets = append(filteredTargets, target)
}
targets = dedupeEmails(filteredTargets)
if len(targets) == 0 {
return nil
}
raw, err := a.forwardingRawMessage(ctx, messageID)
if err != nil {
return err
}
if hasForwardingHeader(raw) {
a.log.Warn("skip rule forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
return nil
}
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
var rfcMessageID string
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
if strings.TrimSpace(rfcMessageID) == "" {
rfcMessageID = messageID
}
queueID, err := a.enqueueSend(ctx, sendQueueInput{
UserID: userID,
MailboxID: mailboxID,
SentMessageID: messageID,
MessageID: ruleForwardQueueMessageID(rfcMessageID, targets),
Source: sendSourceRuleForwarding,
MailFrom: mailboxAddress,
HeaderFrom: mailboxAddress,
Recipients: targets,
MIMEBytes: forwarded,
Now: a.now().UTC(),
})
if err != nil {
return err
}
if queueID == "" {
a.log.Warn("rule forwarding target configured but SMTP sending is not configured", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ","))
}
return nil
}
func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (targetEmails []string, userID, mailboxAddress string, err error) {
var mailboxTarget, mailboxTargetsJSON, accountTarget, accountTargetsJSON string
err = a.db.QueryRowContext(ctx, `SELECT mb.user_id,mb.address,COALESCE(mfs.target_email,''),COALESCE(mfs.target_emails,'[]'),COALESCE(afs.target_email,''),COALESCE(afs.target_emails,'[]')
@@ -130,6 +189,21 @@ func (a *App) forwardingRawMessage(ctx context.Context, messageID string) ([]byt
})
}
func splitRuleForwardTargets(value string) []string {
return strings.FieldsFunc(value, func(r rune) bool {
return unicode.IsSpace(r) || r == ',' || r == '' || r == ';' || r == ''
})
}
func ruleForwardQueueMessageID(messageID string, targets []string) string {
base := strings.TrimSpace(messageID)
if base == "" {
base = newID("ruleforward")
}
sum := sha256.Sum256([]byte(strings.Join(dedupeEmails(targets), ",")))
return base + "#rule-forward-" + hex.EncodeToString(sum[:])[:12]
}
func hasForwardingHeader(raw []byte) bool {
header := raw
if idx := bytes.Index(raw, []byte("\r\n\r\n")); idx >= 0 {
+36 -2
View File
@@ -494,6 +494,15 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("rule action is required"))
return
}
actions, err := a.cleanRuleActions(r.Context(), user.ID, actions)
if err != nil {
badRequest(w, err)
return
}
if len(actions) == 0 {
badRequest(w, errors.New("rule action is required"))
return
}
conditionsJSON, err := json.Marshal(conditions)
if err != nil {
badRequest(w, err)
@@ -1070,10 +1079,10 @@ func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRul
}
out := []MailRuleAction{}
for _, item := range items {
typ := strings.TrimSpace(item.Type)
typ := strings.ToLower(strings.TrimSpace(item.Type))
value := strings.TrimSpace(item.Value)
labelID := strings.TrimSpace(item.LabelID)
if typ != "archive" && typ != "trash" && typ != "star" && typ != "mark-read" && typ != "label" && typ != "move" {
if typ != "archive" && typ != "trash" && typ != "star" && typ != "mark-read" && typ != "label" && typ != "move" && typ != "forward" {
continue
}
if typ == "label" && value == "" && labelID == "" {
@@ -1082,11 +1091,32 @@ func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRul
if typ == "move" && value == "" {
continue
}
if typ == "forward" && value == "" {
continue
}
out = append(out, MailRuleAction{Type: typ, Value: value, LabelID: labelID})
}
return out
}
func (a *App) cleanRuleActions(ctx context.Context, userID string, actions []MailRuleAction) ([]MailRuleAction, error) {
out := make([]MailRuleAction, 0, len(actions))
for _, action := range actions {
if action.Type == "forward" {
targets, err := a.cleanForwardingTargets(ctx, userID, splitRuleForwardTargets(action.Value))
if err != nil {
return nil, err
}
if len(targets) == 0 {
continue
}
action.Value = strings.Join(targets, ", ")
}
out = append(out, action)
}
return out, nil
}
func legacyConditionValue(items []MailRuleCondition, field string) string {
for _, item := range items {
if item.Field == field && item.Operator == "contains" {
@@ -1326,6 +1356,10 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
if err := a.applyRuleLabel(ctx, mailboxID, messageID, action); err != nil {
return err
}
case "forward":
if err := a.processRuleForwarding(ctx, messageID, mailboxID, action); err != nil {
return err
}
}
}
return nil
+1
View File
@@ -29,6 +29,7 @@ const (
sendSourceSubmission = "submission"
sendSourceOpenAPI = "open_api"
sendSourceForwarding = "forwarding"
sendSourceRuleForwarding = "rule_forwarding"
sendSourceForwardingVerification = "forwarding_verification"
sendQueueStaleAfter = 15 * time.Minute