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 {