diff --git a/.github/release-notes/v1.2.25.md b/.github/release-notes/v1.2.25.md new file mode 100644 index 0000000..549e972 --- /dev/null +++ b/.github/release-notes/v1.2.25.md @@ -0,0 +1,9 @@ +- 修复收信规则移动到自定义文件夹时被错误归入“已归档”的问题,现在会按规则名称真实创建目标文件夹。 +- 修正发件人、附件名、邮件大小和日期条件的匹配边界,拒绝字段不支持的运算符,避免规则保存后永远无法命中。 +- “应用到现有邮件”不再处理已发送和草稿邮件,规则暂停启用时也可执行用户明确选择的现有邮件处理。 +- 完善规则动作失败处理:失败的动作不再误中止后续规则,归档、删除和移动错误不再被静默忽略。 +- 收信规则列表新增适用邮箱显示,并提供独立的上移、下移按钮,多条规则时可完整调整优先级。 +- 新增自定义文件夹图标,支持按名称自动匹配、手动选择以及上传小图标,并内置 Netflix、ChatGPT、账单、购物、旅行、工作等常用类型。 +- 上传图标会在浏览器本地缩放为 64×64 PNG,服务端校验 PNG 文件头并限制在 32 KB;不联网查询品牌,不保留上传原图。 +- 文件夹图标已在侧栏、桌面端与移动端移动菜单中统一显示,数据库升级会自动为旧文件夹补充默认图标。 +- 补充收信规则、自定义文件夹、图标自动匹配、手动图标保留和上传格式安全边界的回归测试。 diff --git a/VERSION b/VERSION index 95a7a34..8060c02 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.24 +1.2.25 diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index e41547d..0bb680d 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -317,6 +317,7 @@ func (a *App) migrate(ctx context.Context) error { mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, name TEXT NOT NULL, role TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT 'folder', sort_order INTEGER NOT NULL DEFAULT 0, uid_validity INTEGER NOT NULL DEFAULT 0, uid_next INTEGER NOT NULL DEFAULT 1, @@ -690,6 +691,9 @@ func (a *App) migrate(ctx context.Context) error { if err := a.migrateFolderSortOrder(ctx); err != nil { return err } + if err := a.migrateFolderIcons(ctx); err != nil { + return err + } if err := a.migrateExternalIMAP(ctx); err != nil { return err } diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 0e722ac..82abaa2 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -1050,6 +1050,191 @@ func TestMailRulesForwardingAction(t *testing.T) { } } +func TestMailRulesExactSenderCustomFolderAndStopProcessing(t *testing.T) { + a := newTestApp(t) + 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-exact-sender", "Sender With Name", "Password123!", nil) + recipient := createTestMailbox(t, admin, domainID, "rule-custom-target", "Rule Target", "Password123!", nil) + + 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 bad map[string]any + if code := rcpt.do("POST", "/api/me/rules", map[string]any{ + "mailboxId": recipient.ID, + "conditions": []map[string]string{{"field": "size", "operator": "contains", "value": "10"}}, + "actions": []map[string]string{{"type": "archive"}}, + }, &bad); code != http.StatusBadRequest { + t.Fatalf("invalid field operator should be rejected code=%d body=%v", code, bad) + } + + createRule := func(name string, action map[string]string, stop bool) { + t.Helper() + var rule MailRule + if code := rcpt.do("POST", "/api/me/rules", map[string]any{ + "mailboxId": recipient.ID, + "name": name, + "conditions": []map[string]string{{"field": "from", "operator": "equals", "value": sender.Address}}, + "actions": []map[string]string{action}, + "stopProcessing": stop, + }, &rule); code != http.StatusCreated { + t.Fatalf("create rule %s code=%d rule=%+v", name, code, rule) + } + } + createRule("fallback archive", map[string]string{"type": "archive"}, false) + createRule("Netflix folder", map[string]string{"type": "move", "value": "Netflix 验证码"}, true) + + 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 code", "text": "123456"}, &sent); code != http.StatusCreated { + t.Fatalf("send code=%d sent=%+v", code, sent) + } + var custom struct { + Items []MailMessage `json:"items"` + } + if code := rcpt.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder="+url.QueryEscape("Netflix 验证码"), nil, &custom); code != http.StatusOK || len(custom.Items) != 1 { + t.Fatalf("custom rule folder code=%d items=%+v", code, custom.Items) + } + var archived struct { + Items []MailMessage `json:"items"` + } + if code := rcpt.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder=Archive", nil, &archived); code != http.StatusOK || len(archived.Items) != 0 { + t.Fatalf("stop processing should prevent fallback archive code=%d items=%+v", code, archived.Items) + } + var icon string + if err := a.db.QueryRow(`SELECT icon FROM folders WHERE mailbox_id=? AND name=?`, recipient.ID, "Netflix 验证码").Scan(&icon); err != nil || icon != "netflix" { + t.Fatalf("rule-created folder icon=%q err=%v", icon, err) + } +} + +func TestFolderIconForName(t *testing.T) { + tests := []struct { + name string + requested string + want string + }{ + {name: "Netflix 验证码", requested: "auto", want: "netflix"}, + {name: "ChatGPT 通知", want: "chatgpt"}, + {name: "OpenAI 账单", want: "chatgpt"}, + {name: "项目归档", want: "briefcase"}, + {name: "其他", want: "folder"}, + {name: "Netflix", requested: "heart", want: "heart"}, + {name: "Netflix", requested: "unknown", want: "folder"}, + {name: "Custom", requested: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", want: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="}, + {name: "Egypt archive", want: "folder"}, + {name: "Custom", requested: "data:image/svg+xml;base64,PHN2Zz4=", want: "folder"}, + {name: "Custom", requested: "data:image/png;base64,SGVsbG8=", want: "folder"}, + } + for _, tt := range tests { + t.Run(tt.name+"/"+tt.requested, func(t *testing.T) { + if got := folderIconForName(tt.name, tt.requested); got != tt.want { + t.Fatalf("folderIconForName(%q, %q)=%q want %q", tt.name, tt.requested, got, tt.want) + } + }) + } +} + +func TestRuleFolderAutoIconPreservesManualSelection(t *testing.T) { + a := newTestApp(t) + ctx := context.Background() + admin := &testClient{t: t, server: httptest.NewServer(a.Router())} + defer admin.server.Close() + + 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 := createTestDomain(t, admin, "manual-icon.test") + mailbox := createTestMailbox(t, admin, domainID.ID, "rules", "Rules", "Password123!", nil) + if _, err := a.ensureCustomFolder(ctx, mailbox.ID, "Netflix", "heart"); err != nil { + t.Fatalf("create custom folder: %v", err) + } + if _, err := a.ensureCustomFolder(ctx, mailbox.ID, "Netflix", "auto"); err != nil { + t.Fatalf("reuse custom folder: %v", err) + } + var icon string + if err := a.db.QueryRowContext(ctx, `SELECT icon FROM folders WHERE mailbox_id=? AND name='Netflix'`, mailbox.ID).Scan(&icon); err != nil || icon != "heart" { + t.Fatalf("manual icon should be preserved icon=%q err=%v", icon, err) + } +} + +func TestMailRuleApplyExistingWhenDisabledExcludesSent(t *testing.T) { + a := newTestApp(t) + 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-existing-sender", "Existing Sender", "Password123!", nil) + recipient := createTestMailbox(t, admin, domainID, "rule-existing-recipient", "Existing Recipient", "Password123!", nil) + subject := "same inbound and sent subject" + + 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 incomingSend MailMessage + if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{recipient.Address}, "subject": subject, "text": "incoming"}, &incomingSend); code != http.StatusCreated { + t.Fatalf("incoming send code=%d", code) + } + + 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 outgoing MailMessage + if code := rcpt.do("POST", "/api/mail/send", map[string]any{"to": []string{sender.Address}, "subject": subject, "text": "outgoing"}, &outgoing); code != http.StatusCreated { + t.Fatalf("outgoing send code=%d", code) + } + var rule MailRule + if code := rcpt.do("POST", "/api/me/rules", map[string]any{ + "mailboxId": recipient.ID, + "name": "existing disabled", + "conditions": []map[string]string{{"field": "subject", "operator": "equals", "value": subject}}, + "actions": []map[string]string{{"type": "star"}}, + "applyToExisting": true, + "enabled": false, + }, &rule); code != http.StatusCreated || rule.AppliedExistingCount != 1 || rule.Enabled { + t.Fatalf("create disabled existing rule code=%d rule=%+v", code, rule) + } + var inboundStarred, sentStarred int + if err := a.db.QueryRow(`SELECT is_starred FROM messages WHERE mailbox_id=? AND subject=? AND folder_id IN (SELECT id FROM folders WHERE mailbox_id=? AND lower(name)='inbox')`, recipient.ID, subject, recipient.ID).Scan(&inboundStarred); err != nil { + t.Fatal(err) + } + if err := a.db.QueryRow(`SELECT is_starred FROM messages WHERE id=?`, outgoing.ID).Scan(&sentStarred); err != nil { + t.Fatal(err) + } + if inboundStarred != 1 || sentStarred != 0 { + t.Fatalf("existing rule starred inbound=%d sent=%d", inboundStarred, sentStarred) + } +} + +func TestRuleAttachmentConditionUsesFilenameOnly(t *testing.T) { + msg := ruleMessage{AttachmentNames: "notes.txt"} + if ruleConditionMatches(MailRuleCondition{Field: "attachment", Operator: "contains", Value: "pdf"}, msg) { + t.Fatal("attachment condition must not match MIME type or unrelated extension") + } + if !ruleConditionMatches(MailRuleCondition{Field: "attachment", Operator: "ends-with", Value: ".txt"}, msg) { + t.Fatal("attachment condition should match filename") + } +} + func TestMailRulesMailboxIsolation(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) @@ -2293,7 +2478,7 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) { } var custom MailFolder - if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "客户归档"}, &custom); code != http.StatusCreated || custom.Name != "客户归档" || custom.Role != "客户归档" { + if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "客户归档", "icon": "netflix"}, &custom); code != http.StatusCreated || custom.Name != "客户归档" || custom.Role != "客户归档" || custom.Icon != "netflix" { t.Fatalf("custom folder create code=%d folder=%+v", code, custom) } var folders struct { @@ -2302,6 +2487,15 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) { if code := admin.do("GET", "/api/mail/folders", nil, &folders); code != http.StatusOK || !folderListContains(folders.Items, "客户归档") { t.Fatalf("folder list code=%d items=%+v", code, folders.Items) } + foundIcon := "" + for _, folder := range folders.Items { + if folder.Name == "客户归档" { + foundIcon = folder.Icon + } + } + if foundIcon != "netflix" { + t.Fatalf("folder icon=%q, want netflix", foundIcon) + } var sent MailMessage if code := admin.do("POST", "/api/mail/send", map[string]any{"to": []string{"person@example.test"}, "subject": "custom folder", "text": "body"}, &sent); code != http.StatusCreated { diff --git a/apps/api/internal/app/imap_metadata.go b/apps/api/internal/app/imap_metadata.go index d349b01..7dfcad1 100644 --- a/apps/api/internal/app/imap_metadata.go +++ b/apps/api/internal/app/imap_metadata.go @@ -76,6 +76,10 @@ func (a *App) migrateFolderSortOrder(ctx context.Context) error { return nil } +func (a *App) migrateFolderIcons(ctx context.Context) error { + return a.ensureTableColumn(ctx, "folders", "icon", `ALTER TABLE folders ADD COLUMN icon TEXT NOT NULL DEFAULT 'folder'`) +} + func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error { rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`) if err != nil { diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 0368e05..5df1d6a 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -1,12 +1,14 @@ package app import ( + "bytes" "context" "database/sql" "encoding/base64" "encoding/json" "errors" "fmt" + "image/png" "io" "net/http" "net/textproto" @@ -98,12 +100,12 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusNotFound, "mailbox not found") return } - rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role, + rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,f.icon, COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread, COUNT(m.id) AS total, f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq FROM folders f LEFT JOIN messages m ON m.folder_id=f.id - WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq + WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq ORDER BY CASE WHEN lower(f.name)='inbox' THEN 1000 WHEN lower(f.name)='sent' THEN 5000 @@ -121,7 +123,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) { items := []MailFolder{} for rows.Next() { var f MailFolder - if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil { + if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil { respondError(w, http.StatusInternalServerError, "failed to scan folders") return } @@ -132,7 +134,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) { func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) { user := currentUser(r) - rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role, + rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,MIN(f.icon), COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread, COUNT(m.id) AS total, MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq) @@ -158,7 +160,7 @@ func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) { items := []MailFolder{} for rows.Next() { var f MailFolder - if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil { + if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil { respondError(w, http.StatusInternalServerError, "failed to scan folders") return } @@ -266,6 +268,7 @@ func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) { func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` + Icon string `json:"icon"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) @@ -280,6 +283,7 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) { badRequest(w, errors.New("system folder already exists")) return } + icon := folderIconForName(name, req.Icon) if isAllMailboxID(r.URL.Query().Get("mailboxId")) { user := currentUser(r) rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id`, user.ID) @@ -308,12 +312,12 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) { return } for _, mailboxID := range mailboxIDs { - if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name); err != nil { + if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name, icon); err != nil { respondError(w, http.StatusInternalServerError, "failed to create folder") return } } - respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), SortOrder: customFolderDefaultSortOrderBase}) + respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), Icon: icon, SortOrder: customFolderDefaultSortOrderBase}) return } mb, err := a.mailboxForCurrentUser(r) @@ -321,7 +325,7 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusNotFound, "mailbox not found") return } - folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name) + folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name, icon) if err != nil { respondError(w, http.StatusInternalServerError, "failed to create folder") return @@ -527,8 +531,88 @@ func (a *App) handleDeleteAllMailFolders(w http.ResponseWriter, r *http.Request, respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": moved}) } -func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) { - return a.ensureFolder(ctx, mailboxID, name) +func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name, icon string) (string, error) { + var existingID string + err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND lower(name)=lower(?)`, mailboxID, name).Scan(&existingID) + if err == nil && (strings.TrimSpace(icon) == "" || strings.EqualFold(strings.TrimSpace(icon), "auto")) { + return existingID, nil + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", err + } + id, err := a.ensureFolder(ctx, mailboxID, name) + if err != nil { + return "", err + } + _, err = a.db.ExecContext(ctx, `UPDATE folders SET icon=? WHERE id=? AND mailbox_id=?`, folderIconForName(name, icon), id, mailboxID) + return id, err +} + +func folderIconForName(name, requested string) string { + if icon := strings.TrimSpace(requested); icon != "" && !strings.EqualFold(icon, "auto") { + return normalizeFolderIcon(icon) + } + value := strings.ToLower(strings.TrimSpace(name)) + for _, match := range []struct { + icon string + terms []string + }{ + {"netflix", []string{"netflix", "奈飞", "网飞"}}, + {"chatgpt", []string{"chatgpt", "openai", "gpt"}}, + {"receipt", []string{"账单", "发票", "收据", "bill", "invoice", "receipt"}}, + {"shopping", []string{"购物", "订单", "快递", "shop", "order", "delivery"}}, + {"plane", []string{"旅行", "旅游", "机票", "酒店", "travel", "trip", "flight", "hotel"}}, + {"graduation", []string{"学习", "教育", "课程", "学校", "study", "school", "course"}}, + {"users", []string{"联系人", "团队", "用户", "contact", "team", "people"}}, + {"briefcase", []string{"工作", "项目", "客户", "work", "project", "business", "client"}}, + {"heart", []string{"收藏", "喜欢", "favorite", "favourite"}}, + {"star", []string{"重要", "紧急", "important", "urgent"}}, + {"shield", []string{"安全", "验证", "密码", "登录", "security", "verify", "password", "login"}}, + {"bell", []string{"提醒", "通知", "remind", "notification"}}, + {"mail", []string{"邮件", "邮箱", "mail", "email"}}, + } { + for _, term := range match.terms { + if folderNameContainsTerm(value, term) { + return match.icon + } + } + } + return "folder" +} + +func folderNameContainsTerm(value, term string) bool { + if term != "gpt" { + return strings.Contains(value, term) + } + for _, token := range strings.FieldsFunc(value, func(r rune) bool { + return (r < 'a' || r > 'z') && (r < '0' || r > '9') + }) { + if token == term { + return true + } + } + return false +} + +func normalizeFolderIcon(raw string) string { + icon := strings.TrimSpace(raw) + const customPrefix = "data:image/png;base64," + if strings.HasPrefix(icon, customPrefix) { + data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(icon, customPrefix)) + config, configErr := png.DecodeConfig(bytes.NewReader(data)) + validDimensions := config.Width > 0 && config.Width <= 128 && config.Height > 0 && config.Height <= 128 + if err == nil && configErr == nil && validDimensions && len(data) <= 32*1024 { + return icon + } + return "folder" + } + icon = strings.ToLower(icon) + switch icon { + case "folder", "mail", "briefcase", "users", "receipt", "shopping", "plane", "graduation", "heart", "star", "bell", "shield", "tag", "netflix", "chatgpt": + return icon + default: + return "folder" + } } func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) { @@ -2232,14 +2316,14 @@ func (a *App) handleBulkMove(w http.ResponseWriter, r *http.Request) { } func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) { - row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role, + row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,f.icon, COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread, COUNT(m.id) AS total, f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq FROM folders f LEFT JOIN messages m ON m.folder_id=f.id - WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID) + WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID) var f MailFolder - if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil { + if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil { return nil, err } return &f, nil diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go index c7940af..2735fb1 100644 --- a/apps/api/internal/app/personal_handlers.go +++ b/apps/api/internal/app/personal_handlers.go @@ -534,12 +534,16 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) { return } appliedCount := int64(0) - if req.ApplyToExisting && enabled { - appliedCount, _ = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{ + if req.ApplyToExisting { + appliedCount, err = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{ ID: id, UserID: user.ID, MailboxID: mailboxID, Name: name, MatchMode: matchMode, Conditions: conditions, Actions: actions, ApplyToExisting: req.ApplyToExisting, StopProcessing: req.StopProcessing, FromContains: fromContains, SubjectContains: subjectContains, Action: action, Enabled: enabled, }) + if err != nil { + respondError(w, http.StatusInternalServerError, "rule saved but failed to apply to existing messages") + return + } } row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=?`, id) item, err := scanRule(row) @@ -1330,7 +1334,9 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr if !ruleMatches(rule, msg) { continue } - _ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions) + if err := a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions); err != nil { + continue + } if rule.StopProcessing { break } @@ -1375,7 +1381,7 @@ type ruleMessage struct { func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) { var msg ruleMessage var toAddrs, ccAddrs, receivedAt string - err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID). + err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),from_addr,to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID). Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt) if err != nil { return ruleMessage{}, false @@ -1407,7 +1413,7 @@ func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string if err := rows.Scan(&filename, &contentType); err != nil { return strings.Join(parts, " ") } - parts = append(parts, filename, contentType) + parts = append(parts, filename) } return strings.Join(parts, " ") } @@ -1453,15 +1459,23 @@ func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) { if operator == "" { operator = "contains" } - switch operator { - case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with": - case "gt", "gte", "lt", "lte", "before", "after", "on": - default: + if !validRuleConditionOperator(field, operator) { return MailRuleCondition{}, false } return MailRuleCondition{Field: field, Operator: operator, Value: value}, true } +func validRuleConditionOperator(field, operator string) bool { + switch field { + case "size": + return operator == "gt" || operator == "gte" || operator == "lt" || operator == "lte" || operator == "equals" || operator == "not-equals" + case "date": + return operator == "before" || operator == "after" || operator == "on" || operator == "equals" || operator == "not-equals" + default: + return operator == "contains" || operator == "not-contains" || operator == "equals" || operator == "not-equals" || operator == "starts-with" || operator == "ends-with" + } +} + func normalizeRuleMatchMode(matchMode string) string { switch strings.ToLower(strings.TrimSpace(matchMode)) { case "any", "or": @@ -1710,23 +1724,37 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string, for _, action := range normalizeRuleActions(actions, "") { switch action.Type { case "archive": - if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil { - if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil { - return err - } + folderID, err := a.ensureFolder(ctx, mailboxID, "Archive") + if err != nil { + return err + } + if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil { + return err } case "trash": - if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil { - if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil { - return err - } + folderID, err := a.ensureFolder(ctx, mailboxID, "Trash") + if err != nil { + return err + } + if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil { + return err } case "move": - target := ruleTargetFolder(action.Value) - if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil { - if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil { - return err - } + target, err := normalizeFolderNameForUser(action.Value) + if err != nil { + return err + } + var folderID string + if isSystemFolderName(target) { + folderID, err = a.ensureFolder(ctx, mailboxID, target) + } else { + folderID, err = a.ensureCustomFolder(ctx, mailboxID, target, "auto") + } + if err != nil { + return err + } + if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil { + return err } case "star": starred := true @@ -1789,21 +1817,6 @@ func (a *App) applyRuleLabel(ctx context.Context, mailboxID, messageID string, a return err } -func ruleTargetFolder(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "inbox": - return "Inbox" - case "archive": - return "Archive" - case "spam": - return "Spam" - case "trash": - return "Trash" - default: - return "Archive" - } -} - func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID string, rule MailRule) (int64, error) { args := []any{userID} where := `mb.user_id=?` @@ -1811,7 +1824,7 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID where += ` AND m.mailbox_id=?` args = append(args, mailboxID) } - rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...) + rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id JOIN folders f ON f.id=m.folder_id WHERE `+where+` AND lower(f.name) NOT IN ('sent','drafts')`, args...) if err != nil { return 0, err } diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index 9efcc67..de31c5c 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -88,6 +88,7 @@ type MailFolder struct { ID string `json:"id"` Name string `json:"name"` Role string `json:"role"` + Icon string `json:"icon"` SortOrder int `json:"sortOrder"` UnreadCount int `json:"unreadCount"` TotalCount int `json:"totalCount"` diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 9f2a5aa..3a1c932 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -57,7 +57,7 @@ export type AdminOverview = { users: number; activeUsers: number; domains: numbe export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string } export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; unreadCount?: number; createdAt: string } export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string } -export type MailFolder = { id: string; name: string; role: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number } +export type MailFolder = { id: string; name: string; role: string; icon: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number } export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string } export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number } export type MailMessage = { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 6bdb512..21ac4d3 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -233,9 +233,9 @@ export const api = { externalMessage: (id: string, remoteId: string) => request(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`), markExternalRead: (id: string, remoteId: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }), folders: (mailboxId?: string) => request>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), - createFolder: (payload: { mailboxId?: string; name: string }) => { + createFolder: (payload: { mailboxId?: string; name: string; icon?: string }) => { const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : "" - return request(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) }) + return request(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, icon: payload.icon }) }) }, reorderFolders: (payload: { mailboxId?: string; folderIds: string[]; folders?: { id: string; sortOrder: number }[] }) => { const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : "" diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 18c2ca4..86f60d9 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -11,7 +11,7 @@ import TextAlign from "@tiptap/extension-text-align" import Placeholder from "@tiptap/extension-placeholder" import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style" import { useNavigate } from "react-router-dom" -import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react" +import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bell, Bold, Bot, Briefcase, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, GraduationCap, Heart, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plane, Plus, Quote, Receipt, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, ShoppingBag, Signature, SlidersHorizontal, Smile, Sparkles, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, Upload, Users, X } from "lucide-react" import { api, ExternalImapAccount, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api" import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" @@ -49,6 +49,65 @@ import { useToast } from "@/hooks/use-toast" import { hasPermission } from "@/lib/permissions" const folderIcons: Record = { inbox: , sent: , drafts: , archive: , spam: , trash: } +function NetflixFolderIcon({ className }: { className?: string }) { + return +} +const customFolderIconOptions = [ + { key: "folder", label: "文件夹", icon: Folder }, + { key: "mail", label: "邮件", icon: Mail }, + { key: "briefcase", label: "工作", icon: Briefcase }, + { key: "users", label: "联系人", icon: Users }, + { key: "receipt", label: "账单", icon: Receipt }, + { key: "shopping", label: "购物", icon: ShoppingBag }, + { key: "plane", label: "旅行", icon: Plane }, + { key: "graduation", label: "学习", icon: GraduationCap }, + { key: "heart", label: "收藏", icon: Heart }, + { key: "star", label: "重要", icon: Star }, + { key: "bell", label: "提醒", icon: Bell }, + { key: "shield", label: "安全", icon: ShieldCheck }, + { key: "tag", label: "分类", icon: Tag }, + { key: "netflix", label: "Netflix", icon: NetflixFolderIcon }, + { key: "chatgpt", label: "ChatGPT", icon: Bot }, +] as const +function suggestedFolderIcon(name: string) { + const value = name.trim().toLocaleLowerCase() + if (/netflix|奈飞|网飞/.test(value)) return "netflix" + if (/chatgpt|openai|\bgpt\b/.test(value)) return "chatgpt" + if (/账单|发票|收据|bill|invoice|receipt/.test(value)) return "receipt" + if (/购物|订单|快递|shop|order|delivery/.test(value)) return "shopping" + if (/旅行|旅游|机票|酒店|travel|trip|flight|hotel/.test(value)) return "plane" + if (/学习|教育|课程|学校|study|school|course/.test(value)) return "graduation" + if (/联系人|团队|用户|contact|team|people/.test(value)) return "users" + if (/工作|项目|客户|work|project|business|client/.test(value)) return "briefcase" + if (/收藏|喜欢|favorite|favourite/.test(value)) return "heart" + if (/重要|紧急|important|urgent/.test(value)) return "star" + if (/安全|验证|密码|登录|security|verify|password|login/.test(value)) return "shield" + if (/提醒|通知|remind|notification/.test(value)) return "bell" + if (/邮件|邮箱|mail|email/.test(value)) return "mail" + return "folder" +} + +async function prepareFolderIcon(file: File) { + if (!/^image\/(png|jpe?g|webp)$/i.test(file.type)) throw new Error("仅支持 PNG、JPG 或 WebP 图片") + if (file.size > 2 * 1024 * 1024) throw new Error("原图不能超过 2 MB") + const bitmap = await createImageBitmap(file) + try { + const canvas = document.createElement("canvas") + canvas.width = 64 + canvas.height = 64 + const context = canvas.getContext("2d") + if (!context) throw new Error("无法处理该图片") + const scale = Math.min(64 / bitmap.width, 64 / bitmap.height) + const width = Math.max(1, Math.round(bitmap.width * scale)) + const height = Math.max(1, Math.round(bitmap.height * scale)) + context.drawImage(bitmap, Math.round((64 - width) / 2), Math.round((64 - height) / 2), width, height) + const result = canvas.toDataURL("image/png") + if (result.length > 44_000) throw new Error("处理后的图标过大") + return result + } finally { + bitmap.close() + } +} const folderLabels: Record = { Inbox: "收件箱", Sent: "已发送", @@ -438,7 +497,7 @@ export function MailPage() { onSettled: () => setCancelingScheduledId(""), }) const createFolder = useMutation({ - mutationFn: (name: string) => api.createFolder({ mailboxId: activeMailboxId, name }), + mutationFn: ({ name, icon }: { name: string; icon: string }) => api.createFolder({ mailboxId: activeMailboxId, name, icon }), onSuccess: (created) => { qc.invalidateQueries({ queryKey: ["folders"] }) setFolderDialogOpen(false) @@ -1844,7 +1903,7 @@ export function MailPage() { open={folderDialogOpen} pending={createFolder.isPending} onOpenChange={setFolderDialogOpen} - onCreate={(name) => createFolder.mutate(name)} + onCreate={(payload) => createFolder.mutate(payload)} /> [item.name, item])) - const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), sortOrder: 0, unreadCount: 0, totalCount: 0, uidValidity: 0, uidNext: 1, highestModseq: 1 }) + const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), icon: "folder", sortOrder: 0, unreadCount: 0, totalCount: 0, uidValidity: 0, uidNext: 1, highestModseq: 1 }) for (const item of folders) { if (!normalizedFolders.some((folder) => folder.name === item.name)) normalizedFolders.push(item) } @@ -1872,7 +1931,7 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number, schedul folderId: item.id, folderName: item.name, label: folderLabels[item.name] || item.name, - icon: isCustomMailFolder(item) ? : folderIcons[item.role] || , + icon: isCustomMailFolder(item) ? customFolderIcon(item.icon) : folderIcons[item.role] || , count: item.name === "Drafts" ? item.totalCount : item.unreadCount, custom: isCustomMailFolder(item), order: isCustomMailFolder(item) ? item.sortOrder || 100000 : menuAnchorOrder(item.name), @@ -1892,6 +1951,13 @@ function isCustomMailFolder(folder: Pick) { return !folder.id.startsWith("virtual-") && !["inbox", "sent", "drafts", "archive", "spam", "trash"].includes(folder.name.trim().toLowerCase()) } +function customFolderIcon(iconKey: string | undefined, className = "h-4 w-4") { + if (iconKey?.startsWith("data:image/png;base64,")) return + const option = customFolderIconOptions.find((item) => item.key === iconKey) || customFolderIconOptions[0] + const Icon = option.icon + return +} + function compareMailFolders(a: MailFolder, b: MailFolder) { return (isCustomMailFolder(a) ? a.sortOrder || 100000 : menuAnchorOrder(a.name)) - (isCustomMailFolder(b) ? b.sortOrder || 100000 : menuAnchorOrder(b.name)) || a.name.localeCompare(b.name) } @@ -2513,7 +2579,7 @@ function BulkActionToolbar({ pending, currentFolder, folders = [], readAction = {movableFolders.map((folder) => ( onMoveToFolder(folder.name)}> - {folderIcons[folder.role] || } + {isCustomMailFolder(folder) ? customFolderIcon(folder.icon) : folderIcons[folder.role] || } {folderLabels[folder.name] || folder.name} ))} @@ -2673,7 +2739,7 @@ function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canM
{movableFolders.map((folder) => ( @@ -2718,12 +2784,21 @@ function contextMenuPosition(x: number, y: number) { return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) } } -function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (name: string) => void }) { +function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: { name: string; icon: string }) => void }) { const [name, setName] = React.useState("") + const [icon, setIcon] = React.useState("auto") + const [uploadError, setUploadError] = React.useState("") + const iconInputRef = React.useRef(null) React.useEffect(() => { - if (open) setName("") + if (open) { + setName("") + setIcon("auto") + setUploadError("") + } }, [open]) const trimmed = name.trim() + const suggestedIcon = suggestedFolderIcon(trimmed) + const resolvedIcon = icon === "auto" ? suggestedIcon : icon return ( @@ -2734,13 +2809,51 @@ function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: b className="space-y-4" onSubmit={(event) => { event.preventDefault() - if (trimmed) onCreate(trimmed) + if (trimmed) onCreate({ name: trimmed, icon: resolvedIcon }) }} >
setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />
+
+ 图标 +
+ + {customFolderIconOptions.map((option) => { + const Icon = option.icon + return ( + + ) + })} + + { + const file = event.target.files?.[0] + event.target.value = "" + if (!file) return + try { + setUploadError("") + setIcon(await prepareFolderIcon(file)) + } catch (error) { + setUploadError(error instanceof Error ? error.message : "无法处理该图片") + } + }} + /> +
+ {uploadError &&

{uploadError}

} +
diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index bbafd26..3039605 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -2273,7 +2273,7 @@ function RulesSection({ items, mailboxes, labels, verifiedEmails, open, onOpenCh
- {items.map((item, index) => { setEditingRule(item); onOpenChange(true) }} onToggle={() => onToggle(item)} onMove={(direction) => onMove(item.id, direction)} onApply={() => onApply(item.id)} onDelete={onDelete} />)} + {items.map((item, index) => mailbox.id === item.mailboxId)?.address || "指定邮箱" : "全部邮箱"} pending={pending} onEdit={() => { setEditingRule(item); onOpenChange(true) }} onToggle={() => onToggle(item)} onMove={(direction) => onMove(item.id, direction)} onApply={() => onApply(item.id)} onDelete={onDelete} />)} {items.length === 0 && } text="暂无收件规则" description="新建规则后,可自动标记、移动或转发符合条件的邮件。" className="min-h-[180px] border-solid bg-card" />}
editingRule ? onUpdate(editingRule.id, payload) : onCreate(payload)} /> @@ -2461,12 +2461,10 @@ function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; o return
onCheckedChange(value === true)} />
} -function RuleListItem({ item, index, count, pending, onEdit, onToggle, onMove, onApply, onDelete }: { item: MailRule; index: number; count: number; pending: boolean; onEdit: () => void; onToggle: () => void; onMove: (direction: "up" | "down") => void; onApply: () => void; onDelete: (id: string) => void }) { +function RuleListItem({ item, index, count, mailboxLabel, pending, onEdit, onToggle, onMove, onApply, onDelete }: { item: MailRule; index: number; count: number; mailboxLabel: string; pending: boolean; onEdit: () => void; onToggle: () => void; onMove: (direction: "up" | "down") => void; onApply: () => void; onDelete: (id: string) => void }) { const [confirmOpen, setConfirmOpen] = React.useState(false) const conditionText = ruleConditionSummary(item.conditions, item.fromContains, item.subjectContains) const actionText = item.actions.map(ruleActionSummary).filter(Boolean).join(";") || "无动作" - const moveDirection = index === 0 ? "down" : "up" - const canMove = count > 1 return (
@@ -2475,12 +2473,14 @@ function RuleListItem({ item, index, count, pending, onEdit, onToggle, onMove, o {item.enabled ? "已启用" : "已停用"}
+

适用: {mailboxLabel}

条件: {conditionText}

动作: {actionText}

- + +