feat(mail): 增强邮件规则与阅读体验

- 将收件规则升级为支持多条件、多动作、应用现有邮件和终止继续处理。
- 新增规则构建与显示模式设置,优化规则列表与邮件详情展示。
- 增加邮件列表简洁模式、批量操作、手动刷新和更精细的已读状态控制。
This commit is contained in:
LanQin
2026-06-15 21:50:44 +08:00
parent 96822486af
commit 85fb05d031
12 changed files with 1442 additions and 87 deletions
+91
View File
@@ -7,6 +7,7 @@ import (
"crypto/x509"
"database/sql"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
@@ -236,9 +237,14 @@ func (a *App) migrate(ctx context.Context) error {
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mailbox_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
match_mode TEXT NOT NULL DEFAULT 'all',
conditions_json TEXT NOT NULL DEFAULT '[]',
actions_json TEXT NOT NULL DEFAULT '[]',
from_contains TEXT NOT NULL DEFAULT '',
subject_contains TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
apply_to_existing INTEGER NOT NULL DEFAULT 0,
stop_processing INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
@@ -285,9 +291,94 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateUsersForTwoFactor(ctx); err != nil {
return err
}
if err := a.migrateMailRulesBuilder(ctx); err != nil {
return err
}
return nil
}
func (a *App) migrateMailRulesBuilder(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(mail_rules)`)
if err != nil {
return err
}
defer rows.Close()
columns := map[string]bool{}
for rows.Next() {
var cid int
var name, typ string
var notNull, pk int
var dflt any
if err := rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err != nil {
return err
}
columns[name] = true
}
alter := []struct {
name string
sql string
}{
{"match_mode", `ALTER TABLE mail_rules ADD COLUMN match_mode TEXT NOT NULL DEFAULT 'all'`},
{"conditions_json", `ALTER TABLE mail_rules ADD COLUMN conditions_json TEXT NOT NULL DEFAULT '[]'`},
{"actions_json", `ALTER TABLE mail_rules ADD COLUMN actions_json TEXT NOT NULL DEFAULT '[]'`},
{"apply_to_existing", `ALTER TABLE mail_rules ADD COLUMN apply_to_existing INTEGER NOT NULL DEFAULT 0`},
{"stop_processing", `ALTER TABLE mail_rules ADD COLUMN stop_processing INTEGER NOT NULL DEFAULT 0`},
}
for _, item := range alter {
if !columns[item.name] {
if _, err := a.db.ExecContext(ctx, item.sql); err != nil {
return err
}
}
}
existing, err := a.db.QueryContext(ctx, `SELECT id,from_contains,subject_contains,action,conditions_json,actions_json FROM mail_rules`)
if err != nil {
return err
}
defer existing.Close()
type update struct {
id string
conditions string
actions string
}
updates := []update{}
for existing.Next() {
var id, fromContains, subjectContains, action, conditionsJSON, actionsJSON string
if err := existing.Scan(&id, &fromContains, &subjectContains, &action, &conditionsJSON, &actionsJSON); err != nil {
return err
}
if conditionsJSON != "" && conditionsJSON != "[]" && actionsJSON != "" && actionsJSON != "[]" {
continue
}
conditions := []MailRuleCondition{}
if strings.TrimSpace(fromContains) != "" {
conditions = append(conditions, MailRuleCondition{Field: "from", Operator: "contains", Value: strings.TrimSpace(fromContains)})
}
if strings.TrimSpace(subjectContains) != "" {
conditions = append(conditions, MailRuleCondition{Field: "subject", Operator: "contains", Value: strings.TrimSpace(subjectContains)})
}
actions := []MailRuleAction{}
if strings.TrimSpace(action) != "" {
actions = append(actions, MailRuleAction{Type: strings.TrimSpace(action)})
}
condBytes, err := json.Marshal(conditions)
if err != nil {
return err
}
actionBytes, err := json.Marshal(actions)
if err != nil {
return err
}
updates = append(updates, update{id: id, conditions: string(condBytes), actions: string(actionBytes)})
}
for _, item := range updates {
if _, err := a.db.ExecContext(ctx, `UPDATE mail_rules SET conditions_json=?, actions_json=? WHERE id=?`, item.conditions, item.actions, item.id); err != nil {
return err
}
}
return existing.Err()
}
func (a *App) migrateUsersForTwoFactor(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`)
if err != nil {
+4 -2
View File
@@ -272,8 +272,10 @@ func (a *App) handleMailMessage(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "message not found")
return
}
_, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
msg.IsRead = true
if r.URL.Query().Get("markRead") != "0" && !msg.IsRead {
_, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
msg.IsRead = true
}
respondJSON(w, http.StatusOK, msg)
}
+348 -43
View File
@@ -2,6 +2,8 @@ package app
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
@@ -85,7 +87,7 @@ func (a *App) handleDeleteContact(w http.ResponseWriter, r *http.Request) {
func (a *App) handleListRules(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,name,from_contains,subject_contains,action,enabled,created_at FROM mail_rules WHERE user_id=? ORDER BY created_at DESC`, user.ID)
rows, err := a.db.QueryContext(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 user_id=? ORDER BY created_at DESC`, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load rules")
return
@@ -106,12 +108,17 @@ func (a *App) handleListRules(w http.ResponseWriter, r *http.Request) {
func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
var req struct {
MailboxID string `json:"mailboxId"`
Name string `json:"name"`
FromContains string `json:"fromContains"`
SubjectContains string `json:"subjectContains"`
Action string `json:"action"`
Enabled *bool `json:"enabled"`
MailboxID string `json:"mailboxId"`
Name string `json:"name"`
MatchMode string `json:"matchMode"`
Conditions []MailRuleCondition `json:"conditions"`
Actions []MailRuleAction `json:"actions"`
FromContains string `json:"fromContains"`
SubjectContains string `json:"subjectContains"`
Action string `json:"action"`
ApplyToExisting bool `json:"applyToExisting"`
StopProcessing bool `json:"stopProcessing"`
Enabled *bool `json:"enabled"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
@@ -122,17 +129,37 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
action := strings.TrimSpace(req.Action)
if action != "archive" && action != "trash" && action != "star" && action != "mark-read" {
badRequest(w, errors.New("invalid rule action"))
matchMode := strings.TrimSpace(req.MatchMode)
if matchMode == "" {
matchMode = "all"
}
if matchMode != "all" && matchMode != "any" {
badRequest(w, errors.New("invalid match mode"))
return
}
fromContains := strings.TrimSpace(req.FromContains)
subjectContains := strings.TrimSpace(req.SubjectContains)
if fromContains == "" && subjectContains == "" {
conditions := normalizeRuleConditions(req.Conditions, req.FromContains, req.SubjectContains)
if len(conditions) == 0 {
badRequest(w, errors.New("rule condition is required"))
return
}
actions := normalizeRuleActions(req.Actions, req.Action)
if len(actions) == 0 {
badRequest(w, errors.New("rule action is required"))
return
}
conditionsJSON, err := json.Marshal(conditions)
if err != nil {
badRequest(w, err)
return
}
actionsJSON, err := json.Marshal(actions)
if err != nil {
badRequest(w, err)
return
}
fromContains := legacyConditionValue(conditions, "from")
subjectContains := legacyConditionValue(conditions, "subject")
action := actions[0].Type
name := strings.TrimSpace(req.Name)
if name == "" {
name = "收件规则"
@@ -143,18 +170,27 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
}
id := newID("rule")
now := a.now().UTC().Format(time.RFC3339Nano)
_, err := a.db.ExecContext(r.Context(), `INSERT INTO mail_rules(id,user_id,mailbox_id,name,from_contains,subject_contains,action,enabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, user.ID, mailboxID, name, fromContains, subjectContains, action, boolInt(enabled), now, now)
_, err = a.db.ExecContext(r.Context(), `INSERT INTO mail_rules(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,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, user.ID, mailboxID, name, matchMode, string(conditionsJSON), string(actionsJSON), fromContains, subjectContains, action, boolInt(req.ApplyToExisting), boolInt(req.StopProcessing), boolInt(enabled), now, now)
if err != nil {
badRequest(w, err)
return
}
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,from_contains,subject_contains,action,enabled,created_at FROM mail_rules WHERE id=?`, id)
appliedCount := int64(0)
if req.ApplyToExisting && enabled {
appliedCount, _ = 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,
})
}
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)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load rule")
return
}
item.AppliedExistingCount = appliedCount
respondJSON(w, http.StatusCreated, item)
}
@@ -387,9 +423,19 @@ func scanContact(row messageSummaryScanner) (Contact, error) {
func scanRule(row messageSummaryScanner) (MailRule, error) {
var item MailRule
var enabled int
var enabled, applyToExisting, stopProcessing int
var created string
err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.Name, &item.FromContains, &item.SubjectContains, &item.Action, &enabled, &created)
var conditionsJSON, actionsJSON string
err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.Name, &item.MatchMode, &conditionsJSON, &actionsJSON, &item.FromContains, &item.SubjectContains, &item.Action, &applyToExisting, &stopProcessing, &enabled, &created)
if err == nil {
item.Conditions = decodeRuleConditions(conditionsJSON, item.FromContains, item.SubjectContains)
item.Actions = decodeRuleActions(actionsJSON, item.Action)
if item.MatchMode == "" {
item.MatchMode = "all"
}
}
item.ApplyToExisting = intBool(applyToExisting)
item.StopProcessing = intBool(stopProcessing)
item.Enabled = intBool(enabled)
item.CreatedAt = parseTime(created)
return item, err
@@ -417,37 +463,296 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
}
return
}
rows, err := a.db.QueryContext(ctx, `SELECT from_contains,subject_contains,action FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at`, userID, mailboxID)
rows, err := a.db.QueryContext(ctx, `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 user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at`, userID, mailboxID)
if err != nil {
return
}
defer rows.Close()
lowerFrom := strings.ToLower(from)
lowerSubject := strings.ToLower(subject)
rules := []MailRule{}
for rows.Next() {
var fromContains, subjectContains, action string
if rows.Scan(&fromContains, &subjectContains, &action) != nil {
rule, err := scanRule(rows)
if err == nil {
rules = append(rules, rule)
}
}
rows.Close()
msg := ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
_ = a.db.QueryRowContext(ctx, `SELECT from_addr,to_addrs,subject,snippet,body_text FROM messages WHERE id=?`, messageID).Scan(&msg.From, &msg.To, &msg.Subject, &msg.Snippet, &msg.BodyText)
for _, rule := range rules {
if !ruleMatches(rule, msg) {
continue
}
if fromContains != "" && !strings.Contains(lowerFrom, strings.ToLower(fromContains)) {
continue
}
if subjectContains != "" && !strings.Contains(lowerSubject, strings.ToLower(subjectContains)) {
continue
}
switch action {
case "archive":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
_, _ = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, a.now().UTC().Format(time.RFC3339Nano), messageID)
}
case "trash":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
_, _ = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, a.now().UTC().Format(time.RFC3339Nano), messageID)
}
case "star":
_, _ = a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), messageID)
case "mark-read":
_, _ = a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), messageID)
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
if rule.StopProcessing {
return
}
}
}
type ruleMessage struct {
ID string
MailboxID string
From string
To string
Subject string
Snippet string
BodyText string
}
func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubject string) []MailRuleCondition {
if len(items) == 0 {
if strings.TrimSpace(legacyFrom) != "" {
items = append(items, MailRuleCondition{Field: "from", Operator: "contains", Value: legacyFrom})
}
if strings.TrimSpace(legacySubject) != "" {
items = append(items, MailRuleCondition{Field: "subject", Operator: "contains", Value: legacySubject})
}
}
out := []MailRuleCondition{}
for _, item := range items {
field := strings.TrimSpace(item.Field)
operator := strings.TrimSpace(item.Operator)
value := strings.TrimSpace(item.Value)
if value == "" {
continue
}
if field != "from" && field != "to" && field != "subject" && field != "body" {
continue
}
if operator == "" {
operator = "contains"
}
if operator != "contains" && operator != "not-contains" && operator != "equals" && operator != "not-equals" && operator != "starts-with" && operator != "ends-with" {
continue
}
out = append(out, MailRuleCondition{Field: field, Operator: operator, Value: value})
}
return out
}
func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRuleAction {
if len(items) == 0 && strings.TrimSpace(legacyAction) != "" {
items = append(items, MailRuleAction{Type: strings.TrimSpace(legacyAction)})
}
out := []MailRuleAction{}
for _, item := range items {
typ := 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" {
continue
}
if typ == "label" && value == "" && labelID == "" {
continue
}
if typ == "move" && value == "" {
continue
}
out = append(out, MailRuleAction{Type: typ, Value: value, LabelID: labelID})
}
return out
}
func legacyConditionValue(items []MailRuleCondition, field string) string {
for _, item := range items {
if item.Field == field && item.Operator == "contains" {
return item.Value
}
}
return ""
}
func decodeRuleConditions(raw, legacyFrom, legacySubject string) []MailRuleCondition {
var items []MailRuleCondition
if strings.TrimSpace(raw) != "" {
_ = json.Unmarshal([]byte(raw), &items)
}
return normalizeRuleConditions(items, legacyFrom, legacySubject)
}
func decodeRuleActions(raw, legacyAction string) []MailRuleAction {
var items []MailRuleAction
if strings.TrimSpace(raw) != "" {
_ = json.Unmarshal([]byte(raw), &items)
}
return normalizeRuleActions(items, legacyAction)
}
func ruleMatches(rule MailRule, msg ruleMessage) bool {
conditions := rule.Conditions
if len(conditions) == 0 {
conditions = normalizeRuleConditions(nil, rule.FromContains, rule.SubjectContains)
}
if len(conditions) == 0 {
return false
}
matchMode := rule.MatchMode
if matchMode == "" {
matchMode = "all"
}
matched := 0
for _, condition := range conditions {
if ruleConditionMatches(condition, msg) {
matched++
if matchMode == "any" {
return true
}
} else if matchMode == "all" {
return false
}
}
return matched == len(conditions)
}
func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
var source string
switch condition.Field {
case "from":
source = msg.From
case "to":
source = msg.To
case "subject":
source = msg.Subject
case "body":
source = msg.BodyText
if source == "" {
source = msg.Snippet
}
default:
return false
}
source = strings.ToLower(source)
value := strings.ToLower(condition.Value)
switch condition.Operator {
case "contains":
return strings.Contains(source, value)
case "not-contains":
return !strings.Contains(source, value)
case "equals":
return source == value
case "not-equals":
return source != value
case "starts-with":
return strings.HasPrefix(source, value)
case "ends-with":
return strings.HasSuffix(source, value)
default:
return strings.Contains(source, value)
}
}
func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string, actions []MailRuleAction) error {
now := a.now().UTC().Format(time.RFC3339Nano)
for _, action := range normalizeRuleActions(actions, "") {
switch action.Type {
case "archive":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
return err
}
}
case "trash":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
return err
}
}
case "move":
target := ruleTargetFolder(action.Value)
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
return err
}
}
case "star":
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, updated_at=? WHERE id=?`, now, messageID); err != nil {
return err
}
case "mark-read":
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, now, messageID); err != nil {
return err
}
case "label":
if err := a.applyRuleLabel(ctx, mailboxID, messageID, action); err != nil {
return err
}
}
}
return nil
}
func (a *App) applyRuleLabel(ctx context.Context, mailboxID, messageID string, action MailRuleAction) error {
var label MailLabel
var err error
if action.LabelID != "" {
var count int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mail_labels WHERE id=? AND mailbox_id=?`, action.LabelID, mailboxID).Scan(&count)
if count > 0 {
label.ID = action.LabelID
}
}
if label.ID == "" {
name := strings.TrimSpace(action.Value)
if name == "" {
name = "规则标签"
}
label, err = a.ensureLabel(ctx, mailboxID, name, "")
if err != nil {
return err
}
}
_, err = a.db.ExecContext(ctx, `INSERT OR IGNORE INTO message_labels(message_id,label_id,created_at) VALUES(?,?,?)`, messageID, label.ID, a.now().UTC().Format(time.RFC3339Nano))
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=?`
if mailboxID != "" {
where += ` AND m.mailbox_id=?`
args = append(args, mailboxID)
}
rows, err := a.db.QueryContext(ctx, `SELECT m.id,m.mailbox_id,m.from_addr,m.to_addrs,m.subject,m.snippet,m.body_text FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
if err != nil {
return 0, err
}
messages := []ruleMessage{}
var count int64
for rows.Next() {
var msg ruleMessage
var toAddrs sql.NullString
if err := rows.Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText); err != nil {
return count, err
}
msg.To = toAddrs.String
if !ruleMatches(rule, msg) {
continue
}
messages = append(messages, msg)
}
if err := rows.Err(); err != nil {
return count, err
}
rows.Close()
for _, msg := range messages {
if err := a.applyRuleActions(ctx, msg.MailboxID, msg.ID, rule.Actions); err != nil {
return count, err
}
count++
}
return count, nil
}
+27 -9
View File
@@ -133,15 +133,33 @@ type Contact struct {
}
type MailRule struct {
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
MailboxID string `json:"mailboxId"`
Name string `json:"name"`
FromContains string `json:"fromContains"`
SubjectContains string `json:"subjectContains"`
Action string `json:"action"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"createdAt"`
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
MailboxID string `json:"mailboxId"`
Name string `json:"name"`
MatchMode string `json:"matchMode"`
Conditions []MailRuleCondition `json:"conditions"`
Actions []MailRuleAction `json:"actions"`
ApplyToExisting bool `json:"applyToExisting"`
StopProcessing bool `json:"stopProcessing"`
FromContains string `json:"fromContains"`
SubjectContains string `json:"subjectContains"`
Action string `json:"action"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"createdAt"`
AppliedExistingCount int64 `json:"appliedExistingCount,omitempty"`
}
type MailRuleCondition struct {
Field string `json:"field"`
Operator string `json:"operator"`
Value string `json:"value"`
}
type MailRuleAction struct {
Type string `json:"type"`
Value string `json:"value,omitempty"`
LabelID string `json:"labelId,omitempty"`
}
type BlockedSender struct {
+165
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-avatar": "^1.1.12",
"@radix-ui/react-checkbox": "^1.3.4",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
@@ -365,6 +366,170 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.4.tgz",
"integrity": "sha512-m3JmIOAX5ZzZ6VPjxEU2dbTOhoHi0nT5riwcDwe8idocsWf4a5DXJLDtZ6LfJwMBx7W+A2b7kp2TgPEKtaiF6A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-presence": "1.1.6",
"@radix-ui/react-primitive": "2.1.5",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-previous": "1.1.2",
"@radix-ui/react-use-size": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/primitive": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz",
"integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
"license": "MIT"
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
"integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz",
"integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-presence": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz",
"integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz",
"integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
"integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.3",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
"integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-size": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz",
"integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.0.tgz",
+1
View File
@@ -12,6 +12,7 @@
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.12",
"@radix-ui/react-checkbox": "^1.3.4",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+5 -3
View File
@@ -16,7 +16,9 @@ export type DNSCheckResult = { domain: string; status: string; checks: Record<st
export type ListResponse<T> = { items: T[]; nextCursor?: string }
export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc: string[]; subject: string; text: string; html: string; attachments: { filename: string; contentType: string; contentBase64: string }[] }
export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
export type MailRule = { id: string; mailboxId: string; name: string; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read"; enabled: boolean; createdAt: string }
export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
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 BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
@@ -88,7 +90,7 @@ export const api = {
createContact: (payload: { name: string; email: string; note: string }) => request<Contact>("/api/me/contacts", { method: "POST", body: JSON.stringify(payload) }),
deleteContact: (id: string) => request<{ ok: boolean }>(`/api/me/contacts/${id}`, { method: "DELETE" }),
rules: () => request<ListResponse<MailRule>>("/api/me/rules"),
createRule: (payload: { mailboxId: string; name: string; fromContains: string; subjectContains: string; action: string; enabled: boolean }) => request<MailRule>("/api/me/rules", { method: "POST", body: JSON.stringify(payload) }),
createRule: (payload: { mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; enabled: boolean }) => request<MailRule>("/api/me/rules", { method: "POST", body: JSON.stringify(payload) }),
deleteRule: (id: string) => request<{ ok: boolean }>(`/api/me/rules/${id}`, { method: "DELETE" }),
blockedSenders: () => request<ListResponse<BlockedSender>>("/api/me/blocked-senders"),
createBlockedSender: (payload: { mailboxId: string; email: string; reason: string }) => request<BlockedSender>("/api/me/blocked-senders", { method: "POST", body: JSON.stringify(payload) }),
@@ -153,7 +155,7 @@ export const api = {
if (mailboxId) params.set("mailboxId", mailboxId)
return request<ListResponse<MailMessage>>(`/api/mail/starred?${params.toString()}`)
},
message: (id: string) => request<MailMessage>(`/api/mail/messages/${id}`),
message: (id: string, options: { markRead?: boolean } = {}) => request<MailMessage>(`/api/mail/messages/${id}${options.markRead === false ? "?markRead=0" : ""}`),
send: (payload: SendPayload) => request<MailMessage>("/api/mail/send", { method: "POST", body: JSON.stringify(payload) }),
markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
star: (id: string, starred: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/star`, { method: "POST", body: JSON.stringify({ starred }) }),
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
export type DisplayMode = "detailed" | "compact"
const DISPLAY_MODE_KEY = "lanqin:display-mode"
export function getInitialDisplayMode(): DisplayMode {
if (typeof window === "undefined") return "detailed"
return window.localStorage.getItem(DISPLAY_MODE_KEY) === "compact" ? "compact" : "detailed"
}
export function setStoredDisplayMode(mode: DisplayMode) {
window.localStorage.setItem(DISPLAY_MODE_KEY, mode)
window.dispatchEvent(new CustomEvent("lanqin:display-mode", { detail: mode }))
}
export function useDisplayMode() {
const [displayMode, setDisplayModeState] = React.useState<DisplayMode>(getInitialDisplayMode)
React.useEffect(() => {
function sync() {
setDisplayModeState(getInitialDisplayMode())
}
window.addEventListener("storage", sync)
window.addEventListener("lanqin:display-mode", sync)
return () => {
window.removeEventListener("storage", sync)
window.removeEventListener("lanqin:display-mode", sync)
}
}, [])
const setDisplayMode = React.useCallback((mode: DisplayMode) => {
setStoredDisplayMode(mode)
setDisplayModeState(mode)
}, [])
return [displayMode, setDisplayMode] as const
}
+6
View File
@@ -11,6 +11,12 @@ export function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(date)
}
export function formatDateTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ""
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(date)
}
export function formatBytes(bytes: number) {
if (!bytes) return "0 B"
const units = ["B", "KB", "MB", "GB"]
+465 -19
View File
@@ -4,13 +4,15 @@ import { marked } from "marked"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "react-router-dom"
import type { ImperativePanelHandle } from "react-resizable-panels"
import { Archive, Bold, Check, ChevronsUpDown, Code2, Copy, Forward, Image, Inbox, Italic, Link, List, ListOrdered, Mail, MailCheck, Minus, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, RefreshCcw, Reply, Search, Send, Settings, SlidersHorizontal, Star, Strikethrough, Sun, Tag, Trash2, WrapText, X } from "lucide-react"
import { Archive, ArrowLeft, Bold, Check, ChevronsUpDown, Code2, Copy, Forward, Image, Inbox, Italic, Link, List, ListOrdered, Mail, MailCheck, Minus, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, RefreshCcw, Reply, Search, Send, Settings, SlidersHorizontal, Star, Strikethrough, Sun, Tag, Trash2, WrapText, X } from "lucide-react"
import { api, Mailbox, MailFolder, MailLabel, MailMessage } from "@/lib/api"
import { cn, formatBytes, formatDate } from "@/lib/utils"
import { cn, formatBytes, formatDate, formatDateTime } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
import { useDisplayMode } from "@/lib/display-mode"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
@@ -70,12 +72,16 @@ export function MailPage() {
const [selectedLabelId, setSelectedLabelId] = React.useState("")
const [query, setQuery] = React.useState("")
const [selectedId, setSelectedId] = React.useState<string | null>(null)
const [compactSelectedIds, setCompactSelectedIds] = React.useState<string[]>([])
const [composeOpen, setComposeOpen] = React.useState(false)
const [composeDraft, setComposeDraft] = React.useState<ComposeDraft | undefined>()
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false)
const [mailFilter, setMailFilter] = React.useState<MailFilter>("all")
const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
const [darkMode, setDarkMode] = React.useState(getInitialTheme)
const [displayMode] = useDisplayMode()
const [refreshing, setRefreshing] = React.useState(false)
const [bulkPending, setBulkPending] = React.useState(false)
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
const themeMountedRef = React.useRef(false)
@@ -94,8 +100,36 @@ export function MailPage() {
},
enabled: !!selectedMailboxId && (mailView !== "label" || !!selectedLabelId),
})
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!), enabled: !!selectedId })
const star = useMutation({ mutationFn: ({ id, starred }: { id: string; starred: boolean }) => api.star(id, starred), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }) } })
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId })
function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current)
qc.setQueriesData({ queryKey: ["messages"] }, (current: { items?: MailMessage[] } | undefined) => {
if (!current?.items) return current
return { ...current, items: current.items.map((message) => message.id === id ? { ...message, ...patch } : message) }
})
}
const star = useMutation({
mutationFn: ({ id, starred }: { id: string; starred: boolean }) => api.star(id, starred),
onMutate: ({ id, starred }) => updateCachedMessage(id, { isStarred: starred }),
onSuccess: async (_, variables) => {
await qc.invalidateQueries({ queryKey: ["messages"] })
await qc.invalidateQueries({ queryKey: ["message", variables.id] })
await qc.invalidateQueries({ queryKey: ["mail-stats"] })
await qc.invalidateQueries({ queryKey: ["labels"] })
},
onError: (error) => toast({ title: "操作失败", description: error.message }),
})
const markRead = useMutation({
mutationFn: ({ id, read }: { id: string; read: boolean }) => api.markRead(id, read),
onMutate: ({ id, read }) => updateCachedMessage(id, { isRead: read }),
onSuccess: async (_, variables) => {
await qc.invalidateQueries({ queryKey: ["messages"] })
await qc.invalidateQueries({ queryKey: ["message", variables.id] })
await qc.invalidateQueries({ queryKey: ["folders"] })
await qc.invalidateQueries({ queryKey: ["mail-stats"] })
},
onError: (error) => toast({ title: "操作失败", description: error.message }),
})
const addLabel = useMutation({
mutationFn: ({ id, label }: { id: string; label: MailLabel }) => api.addLabel(id, { name: label.name, color: label.color }),
onSuccess: async (data) => {
@@ -157,6 +191,10 @@ export function MailPage() {
setMailFilter("all")
}, [mailView])
React.useEffect(() => {
setCompactSelectedIds([])
}, [selectedMailboxId, mailView, folder, selectedLabelId, query, displayMode])
React.useEffect(() => {
applyTheme(darkMode, themeMountedRef.current)
themeMountedRef.current = true
@@ -199,6 +237,51 @@ export function MailPage() {
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
const viewTitle = mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
const emptyMessage = allMessages.length === 0 ? (mailView === "starred" ? "暂无星标邮件" : mailView === "label" ? "当前标签没有邮件" : "当前文件夹没有邮件") : "当前筛选条件下没有邮件"
const visibleMessageIds = visibleMessages.map((message) => message.id)
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
function toggleCompactSelectAll(checked: boolean) {
setCompactSelectedIds(checked ? visibleMessageIds : [])
}
function toggleCompactSelect(messageId: string, checked: boolean) {
setCompactSelectedIds((ids) => checked ? Array.from(new Set([...ids, messageId])) : ids.filter((id) => id !== messageId))
}
async function refreshMailData() {
await Promise.all([
qc.invalidateQueries({ queryKey: ["messages"] }),
qc.invalidateQueries({ queryKey: ["folders"] }),
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
qc.invalidateQueries({ queryKey: ["labels"] }),
])
}
async function runBulkAction(action: BulkAction) {
const ids = compactSelectedIds.filter((id) => visibleMessageIds.includes(id))
if (ids.length === 0) return
setBulkPending(true)
try {
if (action === "read" || action === "unread") {
const read = action === "read"
await Promise.all(ids.map((id) => api.markRead(id, read)))
} else if (action === "star" || action === "unstar") {
const starred = action === "star"
await Promise.all(ids.map((id) => api.star(id, starred)))
} else if (action === "delete") {
await Promise.all(ids.map((id) => api.delete(id)))
} else {
const target = action === "archive" ? "Archive" : action === "trash" ? "Trash" : "Spam"
await Promise.all(ids.map((id) => api.move(id, target)))
}
if (selectedId && ids.includes(selectedId)) setSelectedId(null)
setCompactSelectedIds([])
await refreshMailData()
toast({ title: `已处理 ${ids.length} 封邮件` })
} catch (error) {
toast({ title: "批量操作失败", description: error instanceof Error ? error.message : "请稍后重试" })
} finally {
setBulkPending(false)
}
}
function openCompose(draft?: ComposeDraft) { setComposeDraft(draft || { key: `new-${Date.now()}` }); setComposeOpen(true) }
function openReply(message: MailMessage) { openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) }
function openForward(message: MailMessage) { openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) }
@@ -228,6 +311,22 @@ export function MailPage() {
setSelectedId(null)
setMailFilter("all")
}
function openMessage(messageId: string | null) {
setSelectedId(messageId)
if (!messageId) return
const message = allMessages.find((item) => item.id === messageId)
if (message && !message.isRead) {
markRead.mutate({ id: message.id, read: true })
}
}
async function refreshMail() {
setRefreshing(true)
try {
await refreshMailData()
} finally {
setRefreshing(false)
}
}
async function copyCurrentMailbox() {
if (!selectedMailbox?.address) return
await navigator.clipboard.writeText(selectedMailbox.address)
@@ -337,7 +436,7 @@ export function MailPage() {
<section className="flex h-full min-h-0 flex-col">
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-5">
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" onClick={() => { qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }) }}><RefreshCcw className="h-4 w-4" /></Button>
<Button size="icon" variant="ghost" onClick={refreshMail} disabled={refreshing} className={cn("transition-all", refreshing && "bg-primary/5 text-primary")}><RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} /></Button>
<Button variant="outline" size="sm" disabled={markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" /></Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -358,18 +457,54 @@ export function MailPage() {
</div>
</header>
{displayMode === "compact" ? (
<CompactMailView
title={viewTitle}
icon={mailView === "label" && selectedLabel ? <Tag className="h-4 w-4" style={{ color: selectedLabel.color }} /> : undefined}
messages={visibleMessages}
total={allMessages.length}
selectedIds={compactSelectedIds}
allSelected={compactAllSelected}
someSelected={compactSomeSelected}
loading={messages.isLoading}
emptyMessage={emptyMessage}
selectedId={selectedId}
selected={selected}
detailLoading={detail.isLoading}
labels={labelItems}
labelPending={addLabel.isPending || removeLabel.isPending}
onSelect={openMessage}
onSelectAll={toggleCompactSelectAll}
onToggleSelected={toggleCompactSelect}
onCloseReader={() => setSelectedId(null)}
onStar={(message) => star.mutate({ id: message.id, starred: !message.isStarred })}
onReply={openReply}
onForward={openForward}
onArchive={(message) => move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" })}
onDelete={(message) => del.mutate(message.id)}
onToggleRead={(message) => markRead.mutate({ id: message.id, read: !message.isRead })}
onAddLabel={(message, label) => addLabel.mutate({ id: message.id, label })}
onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })}
bulkPending={bulkPending}
onBulkAction={runBulkAction}
/>
) : (
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
<ResizablePanel defaultSize={32} minSize={24} maxSize={44}>
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-14 shrink-0 items-center justify-between border-b px-5">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">{mailView === "label" && selectedLabel && <Tag className="h-4 w-4" style={{ color: selectedLabel.color }} />}{viewTitle}</div>
<div className="text-xs text-muted-foreground">{visibleMessages.length} / {allMessages.length} </div>
<div className="flex min-w-0 items-center gap-3">
<Checkbox aria-label="选择当前页邮件" checked={compactAllSelected ? true : compactSomeSelected ? "indeterminate" : false} onCheckedChange={(value) => toggleCompactSelectAll(value === true)} />
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-semibold">{mailView === "label" && selectedLabel && <Tag className="h-4 w-4" style={{ color: selectedLabel.color }} />}{viewTitle}</div>
<div className="text-xs text-muted-foreground">{selectedCountOnPage > 0 ? `已选 ${selectedCountOnPage}` : `${visibleMessages.length} / ${allMessages.length} 封邮件`}</div>
</div>
</div>
{selectedCountOnPage > 0 && <BulkActionMenu pending={bulkPending} onAction={runBulkAction} />}
</div>
<ScrollArea className="min-h-0 flex-1">
{messages.isLoading && <MessageSkeleton />}
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} onClick={() => setSelectedId(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)}
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)}
{!messages.isLoading && visibleMessages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
</ScrollArea>
</div>
@@ -395,7 +530,7 @@ export function MailPage() {
<Button variant="destructive" size="sm" onClick={() => del.mutate(selected.id)}></Button>
</div>
</div>
<div className="text-sm text-muted-foreground"><span className="font-medium text-foreground">{selected.from}</span> {selected.to.join(", ")} · {formatDate(selected.receivedAt)}</div>
<div className="text-sm text-muted-foreground"><span className="font-medium text-foreground">{selected.from}</span> {selected.to.join(", ")} · {formatDateTime(selected.receivedAt)}</div>
<MessageLabels
messageLabels={selected.labels || []}
availableLabels={labelItems}
@@ -414,6 +549,8 @@ export function MailPage() {
</section>
</ResizablePanel>
</ResizablePanelGroup>
)}
</section>
</ResizablePanel>
</ResizablePanelGroup>
@@ -442,6 +579,257 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number): MailMe
function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> }
function MessageSkeleton() { return <div className="space-y-0">{Array.from({ length: 6 }).map((_, i) => <div className="space-y-2 border-b p-4" key={i}><Skeleton className="h-4 w-1/2" /><Skeleton className="h-4 w-4/5" /><Skeleton className="h-3 w-full" /></div>)}</div> }
type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete"
function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={pending}>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onAction("read")}></DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAction("unread")}></DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAction("star")}></DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAction("unstar")}></DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAction("archive")}></DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAction("trash")}></DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAction("spam")}></DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAction("delete")} className="text-destructive"></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
function CompactMailView({
title,
icon,
messages,
total,
selectedIds,
allSelected,
someSelected,
loading,
emptyMessage,
selectedId,
selected,
detailLoading,
labels,
labelPending,
onSelect,
onSelectAll,
onToggleSelected,
onCloseReader,
onStar,
onReply,
onForward,
onArchive,
onDelete,
onToggleRead,
onAddLabel,
onRemoveLabel,
bulkPending,
onBulkAction,
}: {
title: string
icon?: React.ReactNode
messages: MailMessage[]
total: number
selectedIds: string[]
allSelected: boolean
someSelected: boolean
loading: boolean
emptyMessage: string
selectedId: string | null
selected?: MailMessage
detailLoading: boolean
labels: MailLabel[]
labelPending: boolean
onSelect: (id: string | null) => void
onSelectAll: (checked: boolean) => void
onToggleSelected: (id: string, checked: boolean) => void
onCloseReader: () => void
onStar: (message: MailMessage) => void
onReply: (message: MailMessage) => void
onForward: (message: MailMessage) => void
onArchive: (message: MailMessage) => void
onDelete: (message: MailMessage) => void
onToggleRead: (message: MailMessage) => void
onAddLabel: (message: MailMessage, label: MailLabel) => void
onRemoveLabel: (message: MailMessage, labelId: string) => void
bulkPending: boolean
onBulkAction: (action: BulkAction) => void
}) {
const selectedIndex = selectedId ? messages.findIndex((message) => message.id === selectedId) : -1
const previousMessage = selectedIndex > 0 ? messages[selectedIndex - 1] : undefined
const nextMessage = selectedIndex >= 0 && selectedIndex < messages.length - 1 ? messages[selectedIndex + 1] : undefined
if (selectedId) {
return (
<CompactMessageDetail
selected={selected}
loading={detailLoading}
labels={labels}
labelPending={labelPending}
previousMessage={previousMessage}
nextMessage={nextMessage}
onBack={onCloseReader}
onSelect={onSelect}
onStar={onStar}
onReply={onReply}
onForward={onForward}
onArchive={onArchive}
onDelete={onDelete}
onToggleRead={onToggleRead}
onAddLabel={onAddLabel}
onRemoveLabel={onRemoveLabel}
/>
)
}
return (
<div className="flex min-h-0 flex-1 flex-col bg-background">
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-center gap-3">
<Checkbox aria-label="选择当前页邮件" checked={allSelected ? true : someSelected ? "indeterminate" : false} onCheckedChange={(value) => onSelectAll(value === true)} />
<div className="flex items-center gap-2 text-base font-semibold">{icon}{title}</div>
</div>
<div className="flex items-center gap-2">
{selectedIds.length > 0 ? (
<>
<span className="text-sm text-muted-foreground"> {selectedIds.length} </span>
<BulkActionMenu pending={bulkPending} onAction={onBulkAction} />
</>
) : (
<div className="text-sm text-muted-foreground">{messages.length} / {total} </div>
)}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
{loading && <MessageSkeleton />}
{messages.map((message) => <CompactMessageRow key={message.id} message={message} active={selectedId === message.id} checked={selectedIds.includes(message.id)} onCheckedChange={(checked) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} />)}
{!loading && messages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
</ScrollArea>
</div>
)
}
function CompactMessageDetail({
selected,
loading,
labels,
labelPending,
previousMessage,
nextMessage,
onBack,
onSelect,
onStar,
onReply,
onForward,
onArchive,
onDelete,
onToggleRead,
onAddLabel,
onRemoveLabel,
}: {
selected?: MailMessage
loading: boolean
labels: MailLabel[]
labelPending: boolean
previousMessage?: MailMessage
nextMessage?: MailMessage
onBack: () => void
onSelect: (id: string | null) => void
onStar: (message: MailMessage) => void
onReply: (message: MailMessage) => void
onForward: (message: MailMessage) => void
onArchive: (message: MailMessage) => void
onDelete: (message: MailMessage) => void
onToggleRead: (message: MailMessage) => void
onAddLabel: (message: MailMessage, label: MailLabel) => void
onRemoveLabel: (message: MailMessage, labelId: string) => void
}) {
return (
<div className="flex min-h-0 flex-1 flex-col bg-background">
<div className="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4">
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={onBack}><ArrowLeft className="h-4 w-4" /></Button>
{selected && <Button variant="outline" size="sm" onClick={() => onReply(selected)}><Reply className="h-4 w-4" /></Button>}
{selected && <Button variant="outline" size="sm" onClick={() => onDelete(selected)}><Trash2 className="h-4 w-4" /></Button>}
{selected && <Button variant="outline" size="sm" onClick={() => onForward(selected)}><Forward className="h-4 w-4" /></Button>}
{selected && <Button variant="outline" size="sm" onClick={() => onArchive(selected)}>{selected.folder === "Archive" ? "取消归档" : "归档"}</Button>}
{selected && <Button variant="outline" size="sm" onClick={() => onToggleRead(selected)}><MailCheck className="h-4 w-4" />{selected.isRead ? "标为未读" : "标为已读"}</Button>}
{selected && <Button variant="outline" size="sm" onClick={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</Button>}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" disabled={!previousMessage} onClick={() => previousMessage && onSelect(previousMessage.id)}></Button>
<Button variant="ghost" size="sm" disabled={!nextMessage} onClick={() => nextMessage && onSelect(nextMessage.id)}></Button>
</div>
</div>
{loading && <div className="space-y-4 p-8"><Skeleton className="h-8 w-2/3" /><Skeleton className="h-4 w-1/3" /><Separator /><Skeleton className="h-64 w-full" /></div>}
{!loading && !selected && <div className="grid flex-1 place-items-center text-sm text-muted-foreground"></div>}
{selected && (
<ScrollArea className="min-h-0 flex-1">
<div className="w-full px-8 py-6">
<div className="space-y-5 border-b pb-5">
<div className="flex items-center gap-3">
<h1 className="min-w-0 flex-1 truncate text-2xl font-semibold tracking-tight">{selected.subject}</h1>
<Button type="button" variant="ghost" size="icon" aria-label={selected.isStarred ? "取消星标" : "添加星标"} className="text-muted-foreground hover:text-yellow-500" onClick={() => onStar(selected)}>
<Star className={cn("h-5 w-5", selected.isStarred && "fill-yellow-400 text-yellow-500")} />
</Button>
</div>
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<Avatar className="size-10 rounded-full"><AvatarFallback className="bg-primary text-sm font-semibold text-primary-foreground">{accountInitial(selected.from)}</AvatarFallback></Avatar>
<div className="min-w-0 text-sm">
<div className="truncate font-medium text-foreground">{selected.from}</div>
<div className="truncate text-muted-foreground"> {selected.to.join(", ")}</div>
</div>
</div>
<div className="shrink-0 text-right text-sm text-muted-foreground">{formatDateTime(selected.receivedAt)}</div>
</div>
<MessageLabels
messageLabels={selected.labels || []}
availableLabels={labels}
onAdd={(label) => onAddLabel(selected, label)}
onRemove={(labelId) => onRemoveLabel(selected, labelId)}
pending={labelPending}
/>
</div>
<div className="py-8">
<div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${selected.bodyText || ""}</pre>`) }} />
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium"></div><div className="space-y-2">{selected.attachments.map((a) => <a className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-accent" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a>)}</div></div>}
</div>
</div>
</ScrollArea>
)}
</div>
)
}
function CompactMessageRow({ message, active, checked, onCheckedChange, onClick, onStar }: { message: MailMessage; active: boolean; checked: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onStar: () => void }) {
const visibleLabels = (message.labels || []).slice(0, 2)
return (
<div onClick={onClick} className={cn("grid cursor-pointer grid-cols-[32px_28px_minmax(140px,240px)_minmax(0,1fr)_88px_36px] items-center gap-2 border-b px-4 py-2 text-sm transition-colors hover:bg-accent/50", active && "bg-accent", !message.isRead && "font-semibold")}>
<Checkbox aria-label="选择邮件" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} />
<Mail className={cn("h-4 w-4", message.isRead ? "text-muted-foreground/70" : "fill-yellow-200 text-yellow-500")} />
<div className="truncate">{message.from}</div>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{message.subject}</span>
<span className="min-w-0 truncate text-muted-foreground">{message.snippet}</span>
{visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} compact />)}
{message.hasAttachments && <Paperclip className="h-3 w-3 shrink-0 text-muted-foreground" />}
</div>
<div className="shrink-0 text-right text-xs text-muted-foreground">{formatDate(message.receivedAt)}</div>
<Button type="button" variant="ghost" size="icon" aria-label={message.isStarred ? "取消星标" : "添加星标"} className="h-7 w-7 text-muted-foreground hover:text-yellow-500" onClick={(event) => { event.stopPropagation(); onStar() }}>
<Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} />
</Button>
</div>
)
}
function NewLabelButton({ collapsed, pending, onCreate }: { collapsed: boolean; pending: boolean; onCreate: (name: string) => void }) {
const [editing, setEditing] = React.useState(false)
const [value, setValue] = React.useState("")
@@ -549,15 +937,73 @@ function accountInitial(name: string, email?: string) {
return (first || "蓝").toUpperCase()
}
function MessageRow({ message, active, onClick, onStar }: { message: MailMessage; active: boolean; onClick: () => void; onStar: () => void }) {
function MessageRow({
message,
active,
checked,
onCheckedChange,
onClick,
onStar,
}: {
message: MailMessage
active: boolean
checked: boolean
onCheckedChange: (checked: boolean) => void
onClick: () => void
onStar: () => void
}) {
const visibleLabels = (message.labels || []).slice(0, 2)
const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0)
return <div onClick={onClick} className={cn("cursor-pointer border-b p-4 transition-colors hover:bg-accent/50", active && "bg-accent", !message.isRead && "font-semibold")}>
<div className="mb-1 flex items-center justify-between gap-2"><div className="truncate text-sm">{message.from}</div><div className="shrink-0 text-xs text-muted-foreground">{formatDate(message.receivedAt)}</div></div>
<div className="mb-1 flex items-center gap-2"><Button type="button" variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-yellow-500" onClick={(e) => { e.stopPropagation(); onStar() }}><Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} /></Button><span className="truncate text-sm">{message.subject}</span>{message.hasAttachments && <Paperclip className="h-3 w-3 text-muted-foreground" />}</div>
{message.labels && message.labels.length > 0 && <div className="mb-1 flex flex-wrap gap-1">{message.labels.map((label) => <span key={label.id} className="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium" style={{ borderColor: label.color, color: label.color }}>{label.name}</span>)}</div>}
<div className="line-clamp-2 text-xs text-muted-foreground">{message.snippet}</div>
<div className="flex gap-3">
<Checkbox
aria-label="选择邮件"
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
onClick={(event) => event.stopPropagation()}
className="mt-0.5 shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm">{message.from}</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
aria-label={message.isStarred ? "取消星标" : "添加星标"}
className="h-7 w-7 text-muted-foreground hover:text-yellow-500"
onClick={(e) => { e.stopPropagation(); onStar() }}
>
<Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} />
</Button>
<div className="text-xs text-muted-foreground">{formatDate(message.receivedAt)}</div>
</div>
</div>
<div className="mb-1 flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-sm">{message.subject}</span>
{visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} compact />)}
{hiddenLabelCount > 0 && <Badge variant="outline" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal text-muted-foreground">+{hiddenLabelCount}</Badge>}
{message.hasAttachments && <Paperclip className="h-3 w-3 shrink-0 text-muted-foreground" />}
</div>
<div className="line-clamp-2 text-xs text-muted-foreground">{message.snippet}</div>
</div>
</div>
</div>
}
function MailLabelBadge({ label, compact }: { label: MailLabel; compact?: boolean }) {
return (
<Badge
variant="outline"
className={cn("shrink-0 rounded-md font-normal", compact ? "h-5 px-1.5 text-[11px]" : "h-8 px-2 text-xs")}
style={{ borderColor: label.color, color: label.color }}
>
{label.name}
</Badge>
)
}
function MessageLabels({ messageLabels, availableLabels, onAdd, onRemove, pending }: { messageLabels: MailLabel[]; availableLabels: MailLabel[]; onAdd: (label: MailLabel) => void; onRemove: (labelId: string) => void; pending: boolean }) {
const activeIds = new Set(messageLabels.map((label) => label.id))
return (
@@ -565,12 +1011,12 @@ function MessageLabels({ messageLabels, availableLabels, onAdd, onRemove, pendin
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><Tag className="h-3.5 w-3.5" /></div>
<div className="flex flex-wrap items-center gap-2">
{messageLabels.map((label) => (
<span key={label.id} className="inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs font-medium" style={{ borderColor: label.color, color: label.color }}>
{label.name}
<Badge key={label.id} variant="outline" className="h-8 gap-1.5 rounded-md px-2 text-xs font-normal" style={{ borderColor: label.color, color: label.color }}>
<span>{label.name}</span>
<Button type="button" variant="ghost" size="icon" className="h-4 w-4 rounded-full p-0 hover:bg-black/5" onClick={() => onRemove(label.id)} disabled={pending}>
<X className="h-3 w-3" />
</Button>
</span>
</Badge>
))}
{messageLabels.length === 0 && <span className="text-xs text-muted-foreground"></span>}
<DropdownMenu>
@@ -793,7 +1239,7 @@ function withPrefix(subject: string, prefix: string) { return subject.toLowerCas
function quoteMessage(message: MailMessage) {
const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")
const quote = body.split("\n").map((line) => `> ${line}`).join("\n")
return `\n\n----- 原始邮件 -----\nFrom: ${message.from}\nTo: ${message.to.join(", ")}\nDate: ${formatDate(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}`
return `\n\n----- 原始邮件 -----\nFrom: ${message.from}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}`
}
function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" }
async function fileToAttachment(file: File) {
+266 -11
View File
@@ -2,11 +2,12 @@ import * as React from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import type { ImperativePanelHandle } from "react-resizable-panels"
import { useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, BarChart3, Ban, Contact, Copy, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2 } from "lucide-react"
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
import { QRCodeSVG } from "qrcode.react"
import { api, Mailbox, MailStats } from "@/lib/api"
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailStats } from "@/lib/api"
import { cn, formatBytes } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
import { useMe } from "@/hooks/use-me"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -14,6 +15,8 @@ import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area"
@@ -46,9 +49,9 @@ export function ProfilePage() {
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false)
const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
const [darkMode, setDarkMode] = React.useState(getInitialTheme)
const [ruleMailboxId, setRuleMailboxId] = React.useState("all")
const [ruleAction, setRuleAction] = React.useState("archive")
const [displayMode, setDisplayMode] = useDisplayMode()
const [blockedMailboxId, setBlockedMailboxId] = React.useState("all")
const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false)
const themeMountedRef = React.useRef(false)
const rawTab = params.get("tab") as Tab | null
@@ -58,6 +61,7 @@ export function ProfilePage() {
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts })
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules })
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders })
const ruleLabels = useQuery({ queryKey: ["labels", "rules", mailboxId], queryFn: () => api.labels(mailboxId), enabled: !!mailboxId })
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
const stats = useQuery({ queryKey: ["mail-stats", mailboxId], queryFn: () => api.mailStats(mailboxId), enabled: !!mailboxId })
@@ -97,8 +101,24 @@ export function ProfilePage() {
})
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) } })
const createRule = useMutation({
mutationFn: (form: FormData) => api.createRule({ mailboxId: ruleMailboxId === "all" ? "" : ruleMailboxId, name: String(form.get("name") || ""), fromContains: String(form.get("fromContains") || ""), subjectContains: String(form.get("subjectContains") || ""), action: ruleAction, enabled: true }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "收件规则已保存" }) },
mutationFn: (payload: {
mailboxId: string
name: string
matchMode: "all" | "any"
conditions: MailRuleCondition[]
actions: MailRuleAction[]
applyToExisting: boolean
stopProcessing: boolean
enabled: boolean
}) => api.createRule(payload),
onSuccess: (rule) => {
qc.invalidateQueries({ queryKey: ["rules"] })
qc.invalidateQueries({ queryKey: ["messages"] })
qc.invalidateQueries({ queryKey: ["mail-stats"] })
qc.invalidateQueries({ queryKey: ["labels"] })
setRuleDialogOpen(false)
toast({ title: rule.appliedExistingCount ? `收件规则已保存,已应用 ${rule.appliedExistingCount} 封邮件` : "收件规则已保存" })
},
onError: (error) => toast({ title: "保存失败", description: error.message }),
})
const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) } })
@@ -173,14 +193,14 @@ export function ProfilePage() {
if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/") }} />
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={stats.data} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={ruleMailboxId} action={ruleAction} onMailboxChange={setRuleMailboxId} onActionChange={setRuleAction} onCreate={(form) => createRule.mutate(form)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={ruleLabels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} onRefresh={() => stats.refetch()} />
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={stats.data} twoFactorFormRef={twoFactorFormRef} setupTwoFactor={setupTwoFactor} enableTwoFactor={enableTwoFactor} disableTwoFactor={disableTwoFactor} onCopy={copy} />
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={stats.data} displayMode={displayMode} onDisplayModeChange={setDisplayMode} twoFactorFormRef={twoFactorFormRef} setupTwoFactor={setupTwoFactor} enableTwoFactor={enableTwoFactor} disableTwoFactor={disableTwoFactor} onCopy={copy} />
}
}
function ProfileOverview({ user, profile, password, passwordFormRef, stats, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
function ProfileOverview({ user, profile, password, passwordFormRef, stats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
return (
<div className="space-y-6">
<Card>
@@ -224,6 +244,25 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, twoF
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<Field label="显示模式">
<Select value={displayMode} onValueChange={(value) => onDisplayModeChange(value as DisplayMode)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="detailed"></SelectItem>
<SelectItem value="compact"></SelectItem>
</SelectContent>
</Select>
</Field>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
@@ -326,8 +365,224 @@ function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mail
return <div className="space-y-6"><StatsSummary stats={stats} /><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent className="grid gap-3 md:grid-cols-3"><CleanupButton icon={<MailCheck className="h-4 w-4" />} title="归档已读收件箱" disabled={!mailbox || pending} onClick={() => onCleanup("archive-read-inbox")} /><CleanupButton icon={<MailX className="h-4 w-4" />} title="清空垃圾邮件" disabled={!mailbox || pending} onClick={() => onCleanup("empty-spam")} /><CleanupButton icon={<Trash2 className="h-4 w-4" />} title="清空回收站" disabled={!mailbox || pending} onClick={() => onCleanup("empty-trash")} /></CardContent></Card></div>
}
function RulesSection({ items, mailboxes, mailboxId, action, onMailboxChange, onActionChange, onCreate, onDelete, pending }: { items: any[]; mailboxes: Mailbox[]; mailboxId: string; action: string; onMailboxChange: (value: string) => void; onActionChange: (value: string) => void; onCreate: (form: FormData) => void; onDelete: (id: string) => void; pending: boolean }) {
return <div className="grid gap-6 lg:grid-cols-[420px_minmax(0,1fr)]"><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field label="规则名称"><Input name="name" /></Field><Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={onMailboxChange} /></Field><div className="grid gap-3 md:grid-cols-2"><Field label="发件人包含"><Input name="fromContains" /></Field><Field label="主题包含"><Input name="subjectContains" /></Field></div><Field label="执行动作"><Select value={action} onValueChange={onActionChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="archive"></SelectItem><SelectItem value="trash"></SelectItem><SelectItem value="star"></SelectItem><SelectItem value="mark-read"></SelectItem></SelectContent></Select></Field><Button className="w-full" disabled={pending}>{pending ? "保存中..." : "保存规则"}</Button></form></CardContent></Card><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent className="space-y-2">{items.map((item) => <div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border p-3"><div className="min-w-0"><div className="truncate text-sm font-medium">{item.name}<Badge variant="outline" className="ml-2">{actionLabels[item.action]}</Badge></div><div className="truncate text-xs text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"} · {item.fromContains ? `发件人包含 ${item.fromContains}` : ""} {item.subjectContains ? `主题包含 ${item.subjectContains}` : ""}</div></div><Button variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => onDelete(item.id)}><Trash2 className="h-4 w-4" /></Button></div>)}{items.length === 0 && <EmptyState text="暂无收件规则" />}</CardContent></Card></div>
type RuleCreatePayload = {
mailboxId: string
name: string
matchMode: "all" | "any"
conditions: MailRuleCondition[]
actions: MailRuleAction[]
applyToExisting: boolean
stopProcessing: boolean
enabled: boolean
}
const conditionFieldLabels: Record<MailRuleCondition["field"], string> = { from: "发件人地址", to: "收件人地址", subject: "邮件主题", body: "邮件正文" }
const conditionOperatorLabels: Record<MailRuleCondition["operator"], string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是" }
const ruleActionLabels: Record<MailRuleAction["type"], string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到" }
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 (
<div className="space-y-4">
<div className="flex justify-end">
<Button onClick={() => onOpenChange(true)}><Plus className="h-4 w-4" /></Button>
</div>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="space-y-2">
{items.map((item) => <RuleListItem key={item.id} item={item} mailboxes={mailboxes} onDelete={onDelete} />)}
{items.length === 0 && <EmptyState text="暂无收件规则" />}
</CardContent>
</Card>
<RuleDialog open={open} onOpenChange={onOpenChange} mailboxes={mailboxes} labels={labels} pending={pending} onCreate={onCreate} />
</div>
)
}
function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }: { open: boolean; onOpenChange: (open: boolean) => void; mailboxes: Mailbox[]; labels: MailLabel[]; pending: boolean; onCreate: (payload: RuleCreatePayload) => void }) {
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 [enabled, setEnabled] = React.useState(true)
const [applyToExisting, setApplyToExisting] = React.useState(false)
const [stopProcessing, setStopProcessing] = React.useState(false)
const selectedMailboxId = mailboxId === "all" ? "" : mailboxId
const labelQuery = useQuery({ queryKey: ["labels", "rule-dialog", selectedMailboxId], queryFn: () => api.labels(selectedMailboxId), enabled: !!selectedMailboxId })
const availableLabels = selectedMailboxId ? (labelQuery.data?.items || []) : labels
React.useEffect(() => {
if (!open) return
setName("我的规则")
setMailboxId("all")
setMatchMode("all")
setConditions([{ field: "from", operator: "contains", value: "" }])
setActions([{ type: "label", value: labels[0]?.name || "" }])
setEnabled(true)
setApplyToExisting(false)
setStopProcessing(false)
}, [open, labels])
function updateCondition(index: number, patch: Partial<MailRuleCondition>) {
setConditions((items) => items.map((item, i) => i === index ? { ...item, ...patch } : item))
}
function updateAction(index: number, patch: Partial<MailRuleAction>) {
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 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.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 canCreate = validConditions.length > 0 && validActions.length > 0 && !pending
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!canCreate) return
onCreate({ mailboxId: selectedMailboxId, name: name.trim() || "我的规则", matchMode, conditions: validConditions, actions: validActions, applyToExisting, stopProcessing, enabled })
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(94vw,84rem)] max-w-none gap-0 overflow-hidden p-0">
<DialogHeader className="border-b px-8 py-6">
<DialogTitle className="text-2xl"></DialogTitle>
</DialogHeader>
<form onSubmit={submit}>
<div className="space-y-7 px-8 py-7">
<Field label="名称"><Input value={name} onChange={(event) => setName(event.target.value)} placeholder="我的规则" /></Field>
<Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} /></Field>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 text-sm">
<span></span>
<Select value={matchMode} onValueChange={(value) => setMatchMode(value as "all" | "any")}>
<SelectTrigger className="h-9 w-[132px]"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="all"></SelectItem><SelectItem value="any"></SelectItem></SelectContent>
</Select>
</div>
<div className="space-y-3">
{conditions.map((condition, index) => (
<div key={index} className="grid gap-3 md:grid-cols-[220px_150px_minmax(0,1fr)_auto_auto]">
<Select value={condition.field} onValueChange={(value) => updateCondition(index, { field: value as MailRuleCondition["field"] })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(conditionFieldLabels) as MailRuleCondition["field"][]).map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
</Select>
<Select value={condition.operator} onValueChange={(value) => updateCondition(index, { operator: value as MailRuleCondition["operator"] })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(conditionOperatorLabels) as MailRuleCondition["operator"][]).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
</Select>
<Input value={condition.value} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder="输入值" />
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
</div>
))}
</div>
</div>
<div className="space-y-4">
<div className="text-sm"></div>
<div className="space-y-3">
{actions.map((action, index) => (
<div key={index} className="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)_auto_auto]">
<Select value={action.type} onValueChange={(value) => updateAction(index, { type: value as MailRuleAction["type"], value: "", labelId: "" })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(ruleActionLabels) as MailRuleAction["type"][]).map((value) => <SelectItem key={value} value={value}>{ruleActionLabels[value]}</SelectItem>)}</SelectContent>
</Select>
<RuleActionValue action={action} labels={availableLabels} onChange={(patch) => updateAction(index, patch)} />
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeAction(index)} disabled={actions.length === 1}><X className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" onClick={addAction}><Plus className="h-4 w-4" /></Button>
</div>
))}
</div>
</div>
<Separator />
<div className="space-y-4">
<RuleCheckbox checked={enabled} onCheckedChange={setEnabled} label="立即启用" />
<RuleCheckbox checked={applyToExisting} onCheckedChange={setApplyToExisting} label="应用于现有邮件" />
<div className="flex items-center gap-2">
<RuleCheckbox checked={stopProcessing} onCheckedChange={setStopProcessing} label="终止规则:命中此规则后不再应用其他规则" />
<Info className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</div>
<DialogFooter className="border-t px-8 py-5">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button disabled={!canCreate}>{pending ? "创建中..." : "创建"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function RuleActionValue({ action, labels, onChange }: { action: MailRuleAction; labels: MailLabel[]; onChange: (patch: Partial<MailRuleAction>) => void }) {
if (action.type === "label") {
if (labels.length > 0) {
return (
<Select value={action.value || labels[0].name} onValueChange={(value) => onChange({ value, labelId: labels.find((item) => item.name === value)?.id || "" })}>
<SelectTrigger><SelectValue placeholder="选择标签" /></SelectTrigger>
<SelectContent>{labels.map((label) => <SelectItem key={label.id} value={label.name}>{label.name}</SelectItem>)}</SelectContent>
</Select>
)
}
return <Input value={action.value || ""} onChange={(event) => onChange({ value: event.target.value, labelId: "" })} placeholder="标签名称" />
}
if (action.type === "move") {
return (
<Select value={action.value || "Archive"} onValueChange={(value) => onChange({ value })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="Inbox"></SelectItem><SelectItem value="Archive"></SelectItem><SelectItem value="Spam"></SelectItem><SelectItem value="Trash"></SelectItem></SelectContent>
</Select>
)
}
return <Input value="无需填写" readOnly />
}
function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange: (checked: boolean) => void; label: string }) {
const id = React.useId()
return <div className="flex items-center gap-3"><Checkbox id={id} checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} /><Label htmlFor={id} className="text-base font-medium">{label}</Label></div>
}
function RuleListItem({ item, mailboxes, onDelete }: { item: MailRule; mailboxes: Mailbox[]; onDelete: (id: string) => void }) {
const mailbox = item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"
return (
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium">
<span className="truncate">{item.name}</span>
<Badge variant={item.enabled ? "default" : "secondary"}>{item.enabled ? "启用" : "停用"}</Badge>
{item.actions.map((action, index) => <Badge key={`${action.type}-${index}`} variant="outline">{actionSummary(action)}</Badge>)}
</div>
<div className="truncate text-xs text-muted-foreground">{mailbox} · {item.matchMode === "any" ? "任一条件" : "所有条件"} · {conditionSummary(item.conditions, item.fromContains, item.subjectContains)}</div>
</div>
<Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive" onClick={() => onDelete(item.id)}><Trash2 className="h-4 w-4" /></Button>
</div>
)
}
function normalizeDraftAction(action: MailRuleAction, labels: MailLabel[]): MailRuleAction {
if (action.type === "label") {
const value = action.value || labels[0]?.name || ""
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" }
return { type: action.type }
}
function conditionSummary(conditions: MailRuleCondition[] = [], fromContains = "", subjectContains = "") {
const items = conditions.length > 0 ? conditions : [fromContains ? { field: "from", operator: "contains", value: fromContains } as MailRuleCondition : undefined, subjectContains ? { field: "subject", operator: "contains", value: subjectContains } as MailRuleCondition : undefined].filter(Boolean) as MailRuleCondition[]
return items.map((item) => `${conditionFieldLabels[item.field]} ${conditionOperatorLabels[item.operator]} ${item.value}`).join("") || "无条件"
}
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")}`
return ruleActionLabels[action.type]
}
function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChange, onCreate, onDelete, pending }: { items: any[]; mailboxes: Mailbox[]; mailboxId: string; spamCount: number; onMailboxChange: (value: string) => void; onCreate: (form: FormData) => void; onDelete: (id: string) => void; pending: boolean }) {