diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 32d8c37..138d951 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -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, ¬Null, &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 { diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index c6437b1..483987c 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -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) } diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go index 964eddb..cb8fefe 100644 --- a/apps/api/internal/app/personal_handlers.go +++ b/apps/api/internal/app/personal_handlers.go @@ -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 +} diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index d2cef16..caaa855 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -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 { diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 2a7567a..2d57dfb 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -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", diff --git a/apps/web/package.json b/apps/web/package.json index 82105df..5499d6f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..ddbdd01 --- /dev/null +++ b/apps/web/src/components/ui/checkbox.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 0ae4854..2406f0b 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -16,7 +16,9 @@ export type DNSCheckResult = { domain: string; status: string; checks: Record = { 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("/api/me/contacts", { method: "POST", body: JSON.stringify(payload) }), deleteContact: (id: string) => request<{ ok: boolean }>(`/api/me/contacts/${id}`, { method: "DELETE" }), rules: () => request>("/api/me/rules"), - createRule: (payload: { mailboxId: string; name: string; fromContains: string; subjectContains: string; action: string; enabled: boolean }) => request("/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("/api/me/rules", { method: "POST", body: JSON.stringify(payload) }), deleteRule: (id: string) => request<{ ok: boolean }>(`/api/me/rules/${id}`, { method: "DELETE" }), blockedSenders: () => request>("/api/me/blocked-senders"), createBlockedSender: (payload: { mailboxId: string; email: string; reason: string }) => request("/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>(`/api/mail/starred?${params.toString()}`) }, - message: (id: string) => request(`/api/mail/messages/${id}`), + message: (id: string, options: { markRead?: boolean } = {}) => request(`/api/mail/messages/${id}${options.markRead === false ? "?markRead=0" : ""}`), send: (payload: SendPayload) => request("/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 }) }), diff --git a/apps/web/src/lib/display-mode.ts b/apps/web/src/lib/display-mode.ts new file mode 100644 index 0000000..037cfdc --- /dev/null +++ b/apps/web/src/lib/display-mode.ts @@ -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(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 +} + diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index b34ffe3..2bdbcfe 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -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"] diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 47ede1a..1fe6cd1 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -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(null) + const [compactSelectedIds, setCompactSelectedIds] = React.useState([]) const [composeOpen, setComposeOpen] = React.useState(false) const [composeDraft, setComposeDraft] = React.useState() const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false) const [mailFilter, setMailFilter] = React.useState("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(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) { + 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() {
- + @@ -358,18 +457,54 @@ export function MailPage() {
+ {displayMode === "compact" ? ( + : 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} + /> + ) : (
-
-
{mailView === "label" && selectedLabel && }{viewTitle}
-
{visibleMessages.length} / {allMessages.length} 封邮件
+
+ toggleCompactSelectAll(value === true)} /> +
+
{mailView === "label" && selectedLabel && }{viewTitle}
+
{selectedCountOnPage > 0 ? `已选 ${selectedCountOnPage} 封` : `${visibleMessages.length} / ${allMessages.length} 封邮件`}
+
+ {selectedCountOnPage > 0 && }
{messages.isLoading && } - {visibleMessages.map((m) => setSelectedId(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)} + {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)} {!messages.isLoading && visibleMessages.length === 0 &&
{emptyMessage}
}
@@ -395,7 +530,7 @@ export function MailPage() {
-
{selected.from} 发给 {selected.to.join(", ")} · {formatDate(selected.receivedAt)}
+
{selected.from} 发给 {selected.to.join(", ")} · {formatDateTime(selected.receivedAt)}
+ + )}
@@ -442,6 +579,257 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number): MailMe function FolderSkeleton() { return
} function MessageSkeleton() { return
{Array.from({ length: 6 }).map((_, i) =>
)}
} +type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete" + +function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) { + return ( + + + + + + onAction("read")}>标为已读 + onAction("unread")}>标为未读 + onAction("star")}>添加星标 + onAction("unstar")}>取消星标 + onAction("archive")}>归档 + onAction("trash")}>移入回收站 + onAction("spam")}>移入垃圾邮件 + onAction("delete")} className="text-destructive">删除 + + + ) +} + +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 ( + + ) + } + + return ( +
+
+
+ onSelectAll(value === true)} /> +
{icon}{title}
+
+
+ {selectedIds.length > 0 ? ( + <> + 已选 {selectedIds.length} 封 + + + ) : ( +
{messages.length} / {total} 封
+ )} +
+
+ + {loading && } + {messages.map((message) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} />)} + {!loading && messages.length === 0 &&
{emptyMessage}
} +
+
+ ) +} + +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 ( +
+
+
+ + {selected && } + {selected && } + {selected && } + {selected && } + {selected && } + {selected && } +
+
+ + +
+
+ {loading &&
} + {!loading && !selected &&
邮件不存在
} + {selected && ( + +
+
+
+

{selected.subject}

+ +
+
+
+ {accountInitial(selected.from)} +
+
{selected.from}
+
收件人 {selected.to.join(", ")}
+
+
+
{formatDateTime(selected.receivedAt)}
+
+ onAddLabel(selected, label)} + onRemove={(labelId) => onRemoveLabel(selected, labelId)} + pending={labelPending} + /> +
+
+
${selected.bodyText || ""}`) }} /> + {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => {a.filename}{formatBytes(a.sizeBytes)})}
} +
+
+ + )} +
+ ) +} + +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 ( +
+ onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} /> + +
{message.from}
+
+ {message.subject} + {message.snippet} + {visibleLabels.map((label) => )} + {message.hasAttachments && } +
+
{formatDate(message.receivedAt)}
+ +
+ ) +} + 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
-
{message.from}
{formatDate(message.receivedAt)}
-
{message.subject}{message.hasAttachments && }
- {message.labels && message.labels.length > 0 &&
{message.labels.map((label) => {label.name})}
} -
{message.snippet}
+
+ onCheckedChange(value === true)} + onClick={(event) => event.stopPropagation()} + className="mt-0.5 shrink-0" + /> +
+
+
{message.from}
+
+ +
{formatDate(message.receivedAt)}
+
+
+
+ {message.subject} + {visibleLabels.map((label) => )} + {hiddenLabelCount > 0 && +{hiddenLabelCount}} + {message.hasAttachments && } +
+
{message.snippet}
+
+
} +function MailLabelBadge({ label, compact }: { label: MailLabel; compact?: boolean }) { + return ( + + {label.name} + + ) +} + 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
标签
{messageLabels.map((label) => ( - - {label.name} + + {label.name} - + ))} {messageLabels.length === 0 && 无标签} @@ -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) { diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 595f9ef..6bbb857 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -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 { setMailboxId(id); navigate("/") }} /> if (tab === "contacts") return createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} /> if (tab === "cleanup") return cleanup.mutate(target)} /> - if (tab === "rules") return createRule.mutate(form)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} /> + if (tab === "rules") return createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} /> if (tab === "blocked") return f.role === "spam")?.count || 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} /> if (tab === "stats") return stats.refetch()} /> - return + return } } -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; stats?: MailStats; twoFactorFormRef: React.RefObject; 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; stats?: MailStats; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject; 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 (
@@ -224,6 +244,25 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, twoF + + + 界面设置 + + + + + + + + 双因素认证 @@ -326,8 +365,224 @@ function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mail return
清理当前邮箱} title="归档已读收件箱" disabled={!mailbox || pending} onClick={() => onCleanup("archive-read-inbox")} />} title="清空垃圾邮件" disabled={!mailbox || pending} onClick={() => onCleanup("empty-spam")} />} title="清空回收站" disabled={!mailbox || pending} onClick={() => onCleanup("empty-trash")} />
} -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
新增收件规则
{ e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}>
规则列表{items.map((item) =>
{item.name}{actionLabels[item.action]}
{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"} · {item.fromContains ? `发件人包含 ${item.fromContains}` : ""} {item.subjectContains ? `主题包含 ${item.subjectContains}` : ""}
)}{items.length === 0 && }
+type RuleCreatePayload = { + mailboxId: string + name: string + matchMode: "all" | "any" + conditions: MailRuleCondition[] + actions: MailRuleAction[] + applyToExisting: boolean + stopProcessing: boolean + enabled: boolean +} + +const conditionFieldLabels: Record = { from: "发件人地址", to: "收件人地址", subject: "邮件主题", body: "邮件正文" } +const conditionOperatorLabels: Record = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是" } +const ruleActionLabels: Record = { 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 ( +
+
+ +
+ + 规则列表 + + {items.map((item) => )} + {items.length === 0 && } + + + +
+ ) +} + +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([{ field: "from", operator: "contains", value: "" }]) + const [actions, setActions] = React.useState([{ 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) { + setConditions((items) => items.map((item, i) => i === index ? { ...item, ...patch } : item)) + } + function updateAction(index: number, patch: Partial) { + 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) { + event.preventDefault() + if (!canCreate) return + onCreate({ mailboxId: selectedMailboxId, name: name.trim() || "我的规则", matchMode, conditions: validConditions, actions: validActions, applyToExisting, stopProcessing, enabled }) + } + + return ( + + + + 新建规则 + +
+
+ setName(event.target.value)} placeholder="我的规则" /> + + +
+
+ 当新邮件到达时,满足以下 + +
+
+ {conditions.map((condition, index) => ( +
+ + + updateCondition(index, { value: event.target.value })} placeholder="输入值" /> + + +
+ ))} +
+
+ +
+
执行以下动作
+
+ {actions.map((action, index) => ( +
+ + updateAction(index, patch)} /> + + +
+ ))} +
+
+ + + +
+ + +
+ + +
+
+
+ + + + +
+
+
+ ) +} + +function RuleActionValue({ action, labels, onChange }: { action: MailRuleAction; labels: MailLabel[]; onChange: (patch: Partial) => void }) { + if (action.type === "label") { + if (labels.length > 0) { + return ( + + ) + } + return onChange({ value: event.target.value, labelId: "" })} placeholder="标签名称" /> + } + if (action.type === "move") { + return ( + + ) + } + return +} + +function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange: (checked: boolean) => void; label: string }) { + const id = React.useId() + return
onCheckedChange(value === true)} />
+} + +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 ( +
+
+
+ {item.name} + {item.enabled ? "启用" : "停用"} + {item.actions.map((action, index) => {actionSummary(action)})} +
+
{mailbox} · {item.matchMode === "any" ? "任一条件" : "所有条件"} · {conditionSummary(item.conditions, item.fromContains, item.subjectContains)}
+
+ +
+ ) +} + +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 }) {