feat: add rule forwarding action
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
sendSourceSubmission = "submission"
|
||||
sendSourceOpenAPI = "open_api"
|
||||
sendSourceForwarding = "forwarding"
|
||||
sendSourceRuleForwarding = "rule_forwarding"
|
||||
sendSourceForwardingVerification = "forwarding_verification"
|
||||
|
||||
sendQueueStaleAfter = 15 * time.Minute
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<Tab, { label: string; icon: React.ReactNode }> = {
|
||||
@@ -39,9 +39,7 @@ const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
|
||||
cleanup: { label: "邮件清理", icon: <Trash2 className="h-4 w-4" /> },
|
||||
cleanupQueue: { label: "待清理邮件", icon: <Clock3 className="h-4 w-4" /> },
|
||||
rules: { label: "收信规则", icon: <SlidersHorizontal className="h-4 w-4" /> },
|
||||
sharing: { label: "邮箱共享", icon: <Share2 className="h-4 w-4" /> },
|
||||
blocked: { label: "被拦截邮件", icon: <Ban className="h-4 w-4" /> },
|
||||
transfer: { label: "邮箱转让", icon: <Repeat2 className="h-4 w-4" /> },
|
||||
stats: { label: "数据统计", icon: <BarChart3 className="h-4 w-4" /> },
|
||||
feedback: { label: "反馈", icon: <MessageSquare className="h-4 w-4" /> },
|
||||
apiTokens: { label: "开发者", icon: <Code2 className="h-4 w-4" /> },
|
||||
@@ -53,7 +51,7 @@ const accountSettingTabs: { key: AccountSettingsTab; label: string }[] = [
|
||||
{ key: "clients", label: "通知与客户端" },
|
||||
{ key: "security", label: "安全" },
|
||||
]
|
||||
const actionLabels: Record<string, string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读" }
|
||||
const actionLabels: Record<string, string> = { 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 <CleanupSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} showStats={canViewStats} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
|
||||
if (tab === "cleanupQueue") return <CleanupQueueSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} />
|
||||
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={labels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
|
||||
if (tab === "sharing") return <MailboxSharingSection mailboxes={mailboxes.data?.items || []} onCopy={copy} />
|
||||
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? stats.data?.byFolder.find((f) => 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 <MailboxTransferSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} onSelectMailbox={setMailboxId} />
|
||||
if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} onRefresh={() => stats.refetch()} />
|
||||
if (tab === "feedback") return <FeedbackSection />
|
||||
if (tab === "apiTokens") return <ApiTokensSection items={apiTokens.data?.items || []} loading={apiTokens.isLoading} pending={createApiToken.isPending || updateApiToken.isPending || deleteApiToken.isPending} onCreate={(payload) => 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 (
|
||||
<SettingsCard title="邮箱共享" subtitle="为需要协作查看的邮箱准备共享入口。">
|
||||
<div className="space-y-3">
|
||||
{mailboxes.map((mailbox) => (
|
||||
<div key={mailbox.id} className="flex items-center justify-between gap-3 rounded-lg border p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{mailbox.address}</div>
|
||||
<div className="text-sm text-muted-foreground">当前未共享</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onCopy(mailbox.address)}>复制邮箱</Button>
|
||||
</div>
|
||||
))}
|
||||
{mailboxes.length === 0 && <EmptyState text="暂无可共享邮箱" />}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
function MailboxTransferSection({ mailboxes, selectedMailboxId, onSelectMailbox }: { mailboxes: Mailbox[]; selectedMailboxId: string; onSelectMailbox: (id: string) => void }) {
|
||||
return (
|
||||
<SettingsCard title="邮箱转让" subtitle="选择邮箱并填写目标账号,提交前请确认目标账号已存在。">
|
||||
<form className="space-y-4" onSubmit={(event) => event.preventDefault()}>
|
||||
<Field label="转让邮箱">
|
||||
<Select value={selectedMailboxId || mailboxes[0]?.id || ""} onValueChange={onSelectMailbox}>
|
||||
<SelectTrigger><SelectValue placeholder="选择邮箱" /></SelectTrigger>
|
||||
<SelectContent>{mailboxes.map((mailbox) => <SelectItem key={mailbox.id} value={mailbox.id}>{mailbox.address}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="目标账号"><Input type="email" placeholder="user@example.com" /></Field>
|
||||
<Button type="submit" variant="outline" disabled>提交转让申请</Button>
|
||||
</form>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
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<MailRuleAction["type"], string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到" }
|
||||
const ruleActionLabels: Record<MailRuleAction["type"], string> = { 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<MailRuleCondition[]>([{ field: "from", operator: "contains", value: "" }])
|
||||
const [actions, setActions] = React.useState<MailRuleAction[]>([{ type: "label", value: labels[0]?.name || "" }])
|
||||
const [conditions, setConditions] = React.useState<MailRuleCondition[]>([{ field: "to", operator: "contains", value: "" }])
|
||||
const [actions, setActions] = React.useState<MailRuleAction[]>([{ 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<HTMLFormElement>) {
|
||||
@@ -2715,6 +2673,9 @@ function RuleActionValue({ action, labels, onChange }: { action: MailRuleAction;
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (action.type === "forward") {
|
||||
return <Textarea value={action.value || ""} onChange={(event) => onChange({ value: event.target.value })} className="min-h-[74px] resize-y" placeholder="friend1@example.com, friend2@example.com" />
|
||||
}
|
||||
return <Input value="无需填写" readOnly />
|
||||
}
|
||||
|
||||
@@ -2748,6 +2709,7 @@ function normalizeDraftAction(action: MailRuleAction, labels: MailLabel[]): Mail
|
||||
return { type: "label", value, labelId: labels.find((label) => label.name === value)?.id || action.labelId || "" }
|
||||
}
|
||||
if (action.type === "move") return { type: "move", value: action.value || "Archive" }
|
||||
if (action.type === "forward") return { type: "forward", value: (action.value || "").trim() }
|
||||
return { type: action.type }
|
||||
}
|
||||
|
||||
@@ -2767,6 +2729,8 @@ function conditionPlaceholder(field?: MailRuleCondition["field"]) {
|
||||
if (field === "size") return "例如 10mb"
|
||||
if (field === "date") return "选择日期"
|
||||
if (field === "attachment") return "输入附件名或扩展名"
|
||||
if (field === "to" || field === "from" || field === "cc") return "输入邮箱或关键词"
|
||||
if (field === "subject") return "输入主题关键词"
|
||||
return "输入值"
|
||||
}
|
||||
|
||||
@@ -2788,6 +2752,7 @@ function conditionItemSummary(item: MailRuleCondition): string {
|
||||
function actionSummary(action: MailRuleAction) {
|
||||
if (action.type === "label") return `${ruleActionLabels[action.type]}${action.value ? `:${action.value}` : ""}`
|
||||
if (action.type === "move") return `${ruleActionLabels[action.type]}:${folderLabel(action.value || "Archive")}`
|
||||
if (action.type === "forward") return `${ruleActionLabels[action.type]}${action.value ? `:${action.value}` : ""}`
|
||||
return ruleActionLabels[action.type]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user