diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 27a2a44..e90cbc2 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -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()) diff --git a/apps/api/internal/app/forwarding_delivery.go b/apps/api/internal/app/forwarding_delivery.go index 2a2c4b7..8b56a72 100644 --- a/apps/api/internal/app/forwarding_delivery.go +++ b/apps/api/internal/app/forwarding_delivery.go @@ -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 { diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go index affdec6..9b8e824 100644 --- a/apps/api/internal/app/personal_handlers.go +++ b/apps/api/internal/app/personal_handlers.go @@ -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 diff --git a/apps/api/internal/app/send_queue.go b/apps/api/internal/app/send_queue.go index 122a40c..3bc28d3 100644 --- a/apps/api/internal/app/send_queue.go +++ b/apps/api/internal/app/send_queue.go @@ -29,6 +29,7 @@ const ( sendSourceSubmission = "submission" sendSourceOpenAPI = "open_api" sendSourceForwarding = "forwarding" + sendSourceRuleForwarding = "rule_forwarding" sendSourceForwardingVerification = "forwarding_verification" sendQueueStaleAfter = 15 * time.Minute diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index ef9cd1c..131304b 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -121,8 +121,8 @@ export type MailSignature = { id: string; mailboxId: string; name: string; conte export type MailRuleConditionField = "from" | "to" | "cc" | "subject" | "body" | "attachment" | "size" | "date" export type MailRuleConditionOperator = "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with" | "gt" | "gte" | "lt" | "lte" | "before" | "after" | "on" export type MailRuleCondition = { field?: MailRuleConditionField; operator?: MailRuleConditionOperator; value?: string; matchMode?: "all" | "any"; conditions?: MailRuleCondition[] } -export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; value?: string; labelId?: string } -export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; enabled: boolean; createdAt: string; appliedExistingCount?: number } +export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move" | "forward"; value?: string; labelId?: string } +export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move" | "forward"; enabled: boolean; createdAt: string; appliedExistingCount?: number } export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string } export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; attachmentBytes: number; storageBytes: number; quotaBytes: number; quotaUsedPct: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] } export type ForwardingVerifiedEmail = { diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index a9aa016..ebcb144 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -1,7 +1,7 @@ import * as React from "react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useNavigate, useSearchParams } from "react-router-dom" -import { ArrowLeft, BarChart3, Ban, ChevronDown, Clock3, Code2, Contact, Copy, Image, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, MessageSquare, Moon, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Repeat2, Search, SendHorizontal, Settings, Share2, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react" +import { ArrowLeft, BarChart3, Ban, ChevronDown, Clock3, Code2, Contact, Copy, Image, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, MessageSquare, Moon, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react" import { QRCodeSVG } from "qrcode.react" import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, ForwardingSettings, ForwardingVerifiedEmail, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" import { cn, formatBytes } from "@/lib/utils" @@ -29,7 +29,7 @@ import { ScrollArea } from "@/components/ui/scroll-area" import { ConfirmDialog } from "@/components/confirm-dialog" import { useToast } from "@/hooks/use-toast" -type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "sharing" | "blocked" | "transfer" | "stats" | "feedback" | "apiTokens" +type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "feedback" | "apiTokens" type AccountSettingsTab = "account" | "mail" | "clients" | "security" type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void } const tabs: Record = { @@ -39,9 +39,7 @@ const tabs: Record = { cleanup: { label: "邮件清理", icon: }, cleanupQueue: { label: "待清理邮件", icon: }, rules: { label: "收信规则", icon: }, - sharing: { label: "邮箱共享", icon: }, blocked: { label: "被拦截邮件", icon: }, - transfer: { label: "邮箱转让", icon: }, stats: { label: "数据统计", icon: }, feedback: { label: "反馈", icon: }, apiTokens: { label: "开发者", icon: }, @@ -53,7 +51,7 @@ const accountSettingTabs: { key: AccountSettingsTab; label: string }[] = [ { key: "clients", label: "通知与客户端" }, { key: "security", label: "安全" }, ] -const actionLabels: Record = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读" } +const actionLabels: Record = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到", forward: "规则转发" } export function ProfilePage() { const me = useMe() @@ -93,9 +91,7 @@ export function ProfilePage() { if (key === "cleanup") return canOrganizeMail if (key === "cleanupQueue") return canOrganizeMail if (key === "rules") return canManageRules - if (key === "sharing") return canAccessMail if (key === "blocked") return canManageBlocked - if (key === "transfer") return canAccessMail if (key === "stats") return canViewStats if (key === "feedback") return true if (key === "apiTokens") return true @@ -455,9 +451,7 @@ export function ProfilePage() { if (tab === "cleanup") return cleanup.mutate(target)} /> if (tab === "cleanupQueue") return if (tab === "rules") return createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} /> - if (tab === "sharing") return if (tab === "blocked") return f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} /> - if (tab === "transfer") return if (tab === "stats") return stats.refetch()} /> if (tab === "feedback") return if (tab === "apiTokens") return createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} /> @@ -1025,42 +1019,6 @@ function CleanupQueueSection({ mailbox, stats }: { mailbox?: Mailbox; stats?: Ma ) } -function MailboxSharingSection({ mailboxes, onCopy }: { mailboxes: Mailbox[]; onCopy: (text: string) => void }) { - return ( - -
- {mailboxes.map((mailbox) => ( -
-
-
{mailbox.address}
-
当前未共享
-
- -
- ))} - {mailboxes.length === 0 && } -
-
- ) -} - -function MailboxTransferSection({ mailboxes, selectedMailboxId, onSelectMailbox }: { mailboxes: Mailbox[]; selectedMailboxId: string; onSelectMailbox: (id: string) => void }) { - return ( - -
event.preventDefault()}> - - - - - -
-
- ) -} - function FeedbackSection() { const [sent, setSent] = React.useState(false) return ( @@ -2536,7 +2494,7 @@ const sizeConditionOperators: RuleConditionOperator[] = ["gt", "gte", "lt", "lte const dateConditionOperators: RuleConditionOperator[] = ["before", "after", "on", "equals", "not-equals"] const conditionFields = Object.keys(conditionFieldLabels) as RuleConditionField[] const commonRuleFolders = ["Inbox", "Archive", "Spam", "Trash"] -const ruleActionLabels: Record = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到" } +const ruleActionLabels: Record = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到", forward: "规则转发" } function RulesSection({ items, mailboxes, labels, open, onOpenChange, onCreate, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onDelete: (id: string) => void; pending: boolean }) { return ( @@ -2560,8 +2518,8 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate } const [name, setName] = React.useState("我的规则") const [mailboxId, setMailboxId] = React.useState("all") const [matchMode, setMatchMode] = React.useState<"all" | "any">("all") - const [conditions, setConditions] = React.useState([{ field: "from", operator: "contains", value: "" }]) - const [actions, setActions] = React.useState([{ type: "label", value: labels[0]?.name || "" }]) + const [conditions, setConditions] = React.useState([{ field: "to", operator: "contains", value: "" }]) + const [actions, setActions] = React.useState([{ type: "forward", value: "" }]) const [enabled, setEnabled] = React.useState(true) const [applyToExisting, setApplyToExisting] = React.useState(false) const [stopProcessing, setStopProcessing] = React.useState(false) @@ -2574,8 +2532,8 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate } setName("我的规则") setMailboxId("all") setMatchMode("all") - setConditions([{ field: "from", operator: "contains", value: "" }]) - setActions([{ type: "label", value: labels[0]?.name || "" }]) + setConditions([{ field: "to", operator: "contains", value: "" }]) + setActions([{ type: "forward", value: "" }]) setEnabled(true) setApplyToExisting(false) setStopProcessing(false) @@ -2595,12 +2553,12 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate } setActions((items) => items.map((item, i) => i === index ? normalizeDraftAction({ ...item, ...patch }, availableLabels) : item)) } function addCondition() { setConditions((items) => [...items, { field: "subject", operator: "contains", value: "" }]) } - function addAction() { setActions((items) => [...items, { type: "star" }]) } + function addAction() { setActions((items) => [...items, { type: "forward", value: "" }]) } function removeCondition(index: number) { setConditions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) } function removeAction(index: number) { setActions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) } const validConditions = conditions.map((item) => ({ ...item, value: (item.value || "").trim() })).filter((item) => item.field && item.operator && item.value) - const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value) + const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value).filter((item) => item.type !== "forward" || item.value) const canCreate = validConditions.length > 0 && validActions.length > 0 && !pending function submit(event: React.FormEvent) { @@ -2715,6 +2673,9 @@ function RuleActionValue({ action, labels, onChange }: { action: MailRuleAction; ) } + if (action.type === "forward") { + return