前言

邮件收着收件箱里几千封邮件是什么感觉?营销邮件堆积如山,工作邮件淹没在订阅通知里,而你真正需要的那封却怎么也找不到。作为YYB邮件系统项目的核心组件之一,邮件自动归档引擎(Auto Archive Engine)正是为了解决这个痛点而生。

这篇文章记录了我用 Rust 从零构建这个引擎的完整设计思路:如何用多维度条件灵活描述归档规则、如何在预演模式下安全测试、如何在误归档后一键恢复,以及如何通过 C FFI 将这套能力暴露给 WinUI、Android 等多平台客户端。

一、问题定义与设计目标

1.1 为什么需要自动归档?

手动管理邮件文件夹是一场永无止境的消耗战。我们需要一个系统,能够:

  • 自动化:预定义规则后无需人工干预
  • 可组合:一条策略可以同时匹配多个条件(如"超过30天 AND 已读")
  • 可撤销:误归档后能一键恢复原状
  • 可观测:每次归档操作都有据可查
  • 跨平台:引擎用 Rust 写,通过 FFI 暴露给所有平台的客户端

1.2 核心设计原则

  • 条件与动作分离:ArchiveCondition 只负责判断,ArchiveEngine 负责执行
  • 策略即数据:策略可序列化/反序列化,支持 JSON 导入导出
  • 安全优先:默认开启 Dry-Run 模式,先预览再执行
  • 零运行时依赖:纯 std 库实现,不依赖 Tokio 或 Serde(JSON 亲手解析)

二、核心数据结构

2.1 条件字段与操作符

支持 5 种条件字段(ConditionField)和 5 种比较操作符(ConditionOp):

/// 条件字段
pub enum ConditionField { Age, Size, Label, Folder, Read }

/// 条件操作符
pub enum ConditionOp { Equals, GreaterThan, LessThan, Contains, NotEquals }

/// 归档条件
pub struct ArchiveCondition {
    pub field: ConditionField,   // 判断哪个维度
    pub op: ConditionOp,         // 如何比较
    pub value: String,           // 目标值
}

Age 字段将天数与阈值比较,Size 对比字节数,Label 和 Folder 支持字符串包含匹配,Read 是布尔判断。这种枚举+结构体的设计让条件扩展只需新增一个 ConditionField 变体和对应的 evaluate 分支。

2.2 策略与匹配模式

一条策略(ArchivePolicy)由多个条件和目标文件夹组成。MatchMode 决定条件之间的组合逻辑:

/// 匹配模式:ALL = 全部满足, OR = 任一满足
pub enum MatchMode { All, Any }

pub struct ArchivePolicy {
    pub id: String,
    pub name: String,
    pub conditions: Vec<ArchiveCondition>,
    pub match_mode: MatchMode,
    pub target_folder: String,
    pub enabled: bool,
    pub created_at: i64,
    pub updated_at: i64,
}

impl ArchivePolicy {
    /// 评估条件并返回 (是否满足, 命中数)
    pub fn evaluate(&self, age_days: i64, size: u64, label: &str,
                   folder: &str, is_read: bool) -> (bool, usize) {
        if self.conditions.is_empty() { return (false, 0); }
        let matched = self.conditions.iter()
            .filter(|c| c.evaluate(age_days, size, label, folder, is_read))
            .count();
        (match self.match_mode {
            MatchMode::All => matched == self.conditions.len(),
            MatchMode::Any => matched > 0,
        }, matched)
    }
}

这个 filter + count + match 的组合是策略评估的核心。ALL 模式要求全部条件命中,ANY 模式任一命中即可。返回 (bool, usize) 元组既回答了"要不要归档",又告诉调用者"命中了几条条件"——这对后续的调试和用户反馈至关重要。

三、引擎核心:五步归档流水线

3.1 引擎状态结构

pub struct ArchiveEngine {
    policies: HashMap<String, ArchivePolicy>,   // 策略集
    name_to_id: HashMap<String, String>,        // 名称到ID 索引
    records: Vec<ArchiveRecord>,                 // 归档操作历史
    restored: HashSet<String>,                   // 已恢复的邮件ID(防重归档)
    config: ArchiveEngineConfig,                 // 引擎配置
    stats: ArchiveStats,                         // 运行统计
    last_error: Option<String>,
}

pub struct ArchiveEngineConfig {
    pub dry_run_default: bool,       // 默认开启 Dry-Run
    pub batch_size: usize,           // 单次归档上限(防OOM)
    pub preserve_unread_min_days: i64, // 未读邮件保留天数
}

默认配置下 dry_run_default = true,batch_size = 50,preserve_unread_min_days = 7。这三个默认值经过反复权衡:Dry-Run 是新用户的第一道安全网;batch_size 限制避免一次性操作过多导致 UI 卡顿;7 天未读保留期确保新邮件不被误归档。

3.2 策略管理

pub fn add_policy(&mut self, policy: ArchivePolicy) -> String {
    let id = policy.id.clone();
    self.name_to_id.insert(policy.name.clone(), id.clone());
    self.stats.total_policies += 1;
    if policy.enabled { self.stats.policies_active += 1; }
    self.policies.insert(id.clone(), policy);
    id
}

pub fn remove_policy(&mut self, name: &str) -> bool {
    if let Some(id) = self.name_to_id.remove(name) {
        if let Some(p) = self.policies.remove(&id) {
            self.stats.total_policies = self.stats.total_policies.saturating_sub(1);
            if p.enabled { self.stats.policies_active = self.stats.policies_active.saturating_sub(1); }
            return true;
        }
    }
    false
}

注意这里使用了双向索引:policies 按 ID 存储,name_to_id 提供名称查找。这让我们可以在 O(1) 时间内通过策略名找到策略,同时保持 ID 作为主键的稳定性。saturating_sub 防止计数器下溢。

3.3 候选查询

query_candidates 是归档引擎的"眼睛"——它扫描邮箱,找出所有符合条件的邮件:

pub fn query_candidates(&self, q: &ArchiveQuery) -> Vec<ArchiveCandidate> {
    let emails = Self::mock_emails(q);  // 生产中替换为真实 IMAP 查询
    let mut out = Vec::new();
    let limit = q.limit.unwrap_or(usize::MAX);

    for (eid, subj, folder, size, age, read, label) in emails {
        if out.len() >= limit { break; }

        // 未读邮件保留期检查
        if age < self.config.preserve_unread_min_days && !read { continue; }

        for p in self.policies.values() {
            if !p.enabled { continue; }
            let (matched, cnt) = p.evaluate(age, size, &label, &folder, read);
            if matched {
                out.push(ArchiveCandidate {
                    email_id: eid, subject: subj, folder, size,
                    age_days: age, is_read: read, label,
                    matched_policy_name: p.name.clone(),
                    matched_conditions: cnt,
                    total_conditions: p.conditions.len(),
                    target_folder: p.target_folder.clone(),
                });
                break; // 首匹配即停:一封邮件匹配第一条策略后不再检查后续
            }
        }
    }
    out
}

这里有几个有意思的工程决策:

  • 首匹配即停:一封邮件被第一条匹配的策略归档后就不再检查后续策略。这避免了同一封邮件被重复归档到不同文件夹的歧义。
  • 未读保护:age >= preserve_unread_min_days || is_read 确保新收到的未读邮件不被过早归档。
  • 保留期机制:这是对批量归档最重要的安全阀——即使规则匹配,太新的未读邮件也会被跳过。

3.4 归档执行

pub fn execute_archive(&mut self, candidates: &[ArchiveCandidate]) -> Vec<ArchiveRecord> {
    let mut recs = Vec::new();
    let ts = now();
    let bs = self.config.batch_size;

    for (i, c) in candidates.iter().enumerate() {
        if i >= bs {
            self.stats.emails_skipped += (candidates.len() - i) as u64;
            break; // 超出批次上限,剩余标记为跳过
        }
        let action = ArchiveAction {
            id: format!("act_{}", naid()),
            email_id: c.email_id.clone(),
            source_folder: c.folder.clone(),
            target_folder: c.target_folder.clone(),
            policy_name: c.matched_policy_name.clone(),
            timestamp: ts,
        };
        let ok = !self.restored.contains(&c.email_id);
        let record = ArchiveRecord {
            action,
            success: ok,
            error_message: if ok { None } else { Some(format!("restored: {}", c.email_id)) },
        };
        if ok { self.stats.emails_archived += 1; }
        else { self.stats.errors += 1; }
        recs.push(record);
    }
    if !recs.is_empty() { self.stats.last_archive_time = Some(ts); }
    self.records.extend(recs.clone());
    recs
}

execute_archive 是归档引擎的"手"。三个关键安全设计:

  1. 批次上限:batch_size = 50 意味着每次归档最多移动 50 封邮件。这是对 IMAP 服务器负载和用户注意力的双重保护。
  2. 恢复标记检查:!self.restored.contains(&c.email_id) 防止已恢复的邮件被重复归档。restored 是一个 HashSet<String>。
  3. 全量历史记录:每次执行结果都追加到 self.records 中,配合 last_archive_time 时间戳,实现了完整的操作审计。

3.5 Dry-Run 模式

pub fn dry_run(&self, q: &ArchiveQuery) -> DryRunResult {
    DryRunResult::new(self.query_candidates(q))
}

pub struct DryRunResult {
    pub would_archive: Vec<ArchiveCandidate>,
    pub total_size: u64,
}

impl DryRunResult {
    pub fn new(candidates: Vec<ArchiveCandidate>) -> Self {
        Self {
            total_size: candidates.iter().map(|c| c.size).sum(),
            would_archive: candidates,
        }
    }
}

Dry-Run 是整个归档引擎最重要的安全特性。它调用与真实归档完全相同的 query_candidates 逻辑,但不执行任何写操作,只返回"会归档哪些邮件,总体积多大"。用户可以在 UI 中预览后,决定是否真正执行。这也是为什么 ArchiveEngineConfig::default() 将 dry_run_default 设为 true。

四、恢复机制:错误的后悔药

再完善的规则也会有误判。归档引擎提供两种粒度的恢复能力:

/// 恢复单封邮件到原文件夹
pub fn restore(&mut self, email_id: &str, original_folder: &str) -> bool {
    self.restored.insert(email_id.to_string());
    let mut found = false;
    for r in &mut self.records {
        if r.action.email_id == email_id {
            r.action.target_folder = original_folder.to_string();
            found = true;
        }
    }
    if found { self.stats.emails_archived = self.stats.emails_archived.saturating_sub(1); }
    found
}

/// 恢复所有邮件(一键撤销全部归档)
pub fn restore_all(&mut self) -> usize {
    let n = self.records.len();
    for r in &self.records {
        self.restored.insert(r.action.email_id.clone());
    }
    self.stats.emails_archived = 0;
    n
}

/// 保留策略:清理超过 max_days 的旧归档记录
pub fn enforce_retention(&mut self, max_days: i64) -> usize {
    let cutoff = now() - max_days * 86400;
    let before = self.records.len();
    self.records.retain(|r| r.action.timestamp >= cutoff);
    before - self.records.len()
}

三层恢复/清理机制的分工:

  • restore:单封邮件级别的精确恢复。将邮件 ID 加入 restored 集合,下次归档时自动跳过。
  • restore_all:一键全部撤回。清空所有归档计数,将所有邮件标记为已恢复。
  • enforce_retention:历史记录保留策略。超过指定天数的归档记录会被清理,防止内存无限增长。

五、策略构建助手

为了降低策略创建的门槛,引擎提供了一组工厂函数(Policy Builder Helpers):

/// 按年龄创建策略:"超过 N 天" 到 归档到指定文件夹
pub fn make_age_policy(name: impl Into<String>, days: i64, target: impl Into<String>) -> ArchivePolicy {
    ArchivePolicy::new(name, vec![
        ArchiveCondition::new(ConditionField::Age, ConditionOp::GreaterThan, days.to_string())
    ], MatchMode::All, target)
}

/// 按标签归档:"标签等于 newsletter" 到 Archive/Newsletters
pub fn make_label_policy(name: impl Into<String>, label: impl Into<String>, target: impl Into<String>) -> ArchivePolicy {
    ArchivePolicy::new(name, vec![
        ArchiveCondition::new(ConditionField::Label, ConditionOp::Equals, label.into())
    ], MatchMode::All, target)
}

/// 按已读+年龄归档:"已读 AND 超过 N 天" 到 Archive
pub fn make_read_policy(name: impl Into<String>, days: i64, target: impl Into<String>) -> ArchivePolicy {
    ArchivePolicy::new(name, vec![
        ArchiveCondition::new(ConditionField::Read, ConditionOp::Equals, "true".to_string()),
        ArchiveCondition::new(ConditionField::Age, ConditionOp::GreaterThan, days.to_string()),
    ], MatchMode::All, target)
}

/// 按体积归档:"大于 N 字节" 到 Archive/Large
pub fn make_size_policy(name: impl Into<String>, max_bytes: u64, target: impl Into<String>) -> ArchivePolicy {
    ArchivePolicy::new(name, vec![
        ArchiveCondition::new(ConditionField::Size, ConditionOp::GreaterThan, max_bytes.to_string())
    ], MatchMode::All, target)
}

这组助手覆盖了最常见的归档场景:

  • make_age_policy("Old Emails", 30, "Archive") — 超过 30 天的邮件自动归档
  • make_read_policy("Read Old", 90, "Archive/2026") — 读过且超过 90 天的邮件
  • make_size_policy("Large Attachments", 10*1024*1024, "Archive/Large") — 大于 10MB 的大邮件
  • make_label_policy("Newsletters", "newsletter", "Archive/Newsletters") — 按标签归集订阅邮件

它们的共同点是使用 impl Into<String> 泛型,允许调用者传入 &str、String 或 Cow<str>,提供了零成本抽象。

六、C FFI 接口:引擎的多平台出口

归档引擎作为跨平台邮件系统的核心模块,需要暴露给 WinUI (C++/WinRT)、Android (JNI via C)、iOS (ObjC via C) 等多个客户端。我们通过 20 个 #[no_mangle] pub extern "C" 函数实现了完整的 C ABI 绑定:

// 引擎生命周期
void*  rust_auto_archive_create();
void   rust_auto_archive_destroy(void* handle);
int    rust_auto_archive_is_available();

// 策略管理(增删改查)
const char* rust_auto_archive_add_policy(void* handle, const char* json);
int    rust_auto_archive_remove_policy(void* handle, const char* name);
int    rust_auto_archive_set_policy_enabled(void* handle, const char* name, int enabled);
void   rust_auto_archive_clear_policies(void* handle);
int    rust_auto_archive_policy_count(void* handle);
int    rust_auto_archive_active_count(void* handle);

// 快速创建策略(零 JSON 开销)
const char* rust_auto_archive_quick_policy(void* handle, const char* name,
    const char* conditions_json, const char* mode, const char* target);

// 归档执行
const char* rust_auto_archive_execute(void* handle, const char* query_json);
const char* rust_auto_archive_dry_run(void* handle, const char* query_json);

// 恢复操作
int    rust_auto_archive_restore_message(void* handle, const char* email_id, const char* folder);
int    rust_auto_archive_restore_all(void* handle);

// 查询与导出
const char* rust_auto_archive_query_records(void* handle, const char* email_id);
const char* rust_auto_archive_export_policies(void* handle);
const char* rust_auto_archive_get_stats_json(void* handle);

// 配置与保留策略
void   rust_auto_archive_set_config(void* handle, ...);
int    rust_auto_archive_enforce_retention(void* handle, int max_days);

// 错误处理
const char* rust_auto_archive_last_error();

FFI 设计中的几个注意点:

  • 不透明句柄:外部只看到 void*,内部强转为 ArchiveEngine,避免 C 端依赖 Rust 类型。
  • OnceLock + Mutex + String 全局错误状态:替代了早期的 lazy_static!,使用标准库实现线程安全的错误存储。
  • const char* 返回值:所有 JSON 字符串通过 CString::into_raw() 转移所有权,调用端必须调用 rust_auto_archive_free_string() 释放内存。
  • 错误状态:每次 FFI 调用失败时写入 LAST_ERROR,后续调用 rust_auto_archive_last_error() 即可获取详细原因。

七、JSON 亲手解析:零依赖的工程取舍

这个引擎的 JSON 解析完全手工实现(不使用 serde),原因是:

  1. 编译防火墙:邮件系统引擎在 macOS sandbox 中通过 cargo check --lib --locked 编译,引入 serde 会增加复杂的 trait 推导链,拖慢增量编译。
  2. 确定性行为:手写解析器对异常输入有确定的行为——遇到格式错误的 JSON 条件返回空 Vec 而非 panic,这在生产环境中是必要的容错。
  3. 代码透明:可以直接看到解析逻辑的每一步,没有宏黑盒。
fn parse_conds(json: &str) -> Vec<ArchiveCondition> {
    // 手动处理括号嵌套、字符串转义、字段提取
    let mut objs = Vec::new();
    let mut in_str = false;
    let mut depth: i32 = 0;
    let mut escaped = false;
    let mut cur = String::new();

    for c in json.chars() {
        if escaped { cur.push(c); escaped = false; continue; }
        match c {
            '\\' => { escaped = true; cur.push(c); }
            '"' => { in_str = !in_str; cur.push(c); }
            '[' | ']' if !in_str => {} // 跳过结构括号
            '{' if !in_str => { depth += 1; cur.push(c); }
            '}' if !in_str => {
                depth -= 1; cur.push(c);
                if depth == 0 {
                    objs.push(cur.trim().to_string());
                    cur.clear();
                }
            }
            _ => { cur.push(c); }
        }
    }
    // 每个对象中提取 field/op/value 三个字段,构造条件
    objs.iter().filter_map(|o| {
        let fields = xfields(o);
        // 匹配 field、op、value ...
        Some(ArchiveCondition::new(field, op, value))
    }).collect()
}

如果后续需求变得更复杂(嵌套条件、逻辑组等),会考虑切换到 serde。但至少在当前阶段,手写 JSON 是性能和复杂度的最佳平衡。

八、使用示例与最佳实践

8.1 典型策略组合

一个生产环境中合理的归档策略配置示例:

let mut engine = ArchiveEngine::with_config(ArchiveEngineConfig {
    dry_run_default: true,
    batch_size: 100,
    preserve_unread_min_days: 3, // 3天内新邮件不归档
});

// 策略1:营销邮件自动归集
engine.add_policy(make_label_policy("Newsletters", "newsletter", "Archive/Newsletters"));

// 策略2:30天以上的已读工作邮件归档
engine.add_policy(make_read_policy("Old Work", 30, "Archive/2026"));

// 策略3:90天以上的大附件邮件归档
engine.add_policy(ArchivePolicy::new(
    "Large Old Mail",
    vec![
        ArchiveCondition::new(ConditionField::Age, ConditionOp::GreaterThan, "90"),
        ArchiveCondition::new(ConditionField::Size, ConditionOp::GreaterThan, "5242880"), // 5MB
    ],
    MatchMode::All,  // 两个条件都要满足
    "Archive/Large",
));

// 预览、确认、三步工作流
let preview = engine.dry_run(&ArchiveQuery::new());
println!("Preview: {} emails, {} bytes total", preview.would_archive.len(), preview.total_size);
// 用户点击确认归档后:
let records = engine.execute_archive(&preview.would_archive);
println!("Archived {}", records.iter().filter(|r| r.success).count());

8.2 推荐的运维流程

  1. 先 Dry-Run:每次引擎启动后先跑一次 dry-run,展示"如果不做操作,当前会归档哪些邮件"
  2. 人工确认:UI 显示预览列表,用户可勾选排除
  3. 分批执行:每批 50-100 封,间隔 1 秒,避免 IMAP 服务器过载
  4. 记录与审计:归档后返回操作记录,支持按邮件 ID 查询历史
  5. 定期清理:每周调用 enforce_retention(90) 清理 90 天前的归档记录

九、总结与展望

邮件自动归档引擎是一个看似简单但细节丰富的工程问题。核心挑战不在于"如何移动邮件",而在于:

  • 规则的表达力:如何让用户用简单的方式描述复杂条件
  • 安全感:如何让 Dry-Run 和恢复机制成为默认行为而非可选项
  • 平台无关性:如何用 C FFI 将核心能力无损耗地传递给所有客户端

归档引擎的实现过程不断提醒我:好的自动化不是让人失去控制,而是让人更安心地放手。Dry-Run 模式、批次上限、恢复机制、保留策略——它们不是功能的堆砌,而是对用户信任的一次次确认。

后续迭代方向

  • 定时调度:集成 cron 风格的定时策略,让引擎可以在凌晨自动运行
  • 智能推荐:基于用户历史归档行为,推荐新的归档规则
  • 标签预测:用简单的 TF-IDF 对新邮件自动打标签,辅助归档
  • 版本演进:策略变更历史追踪(git-like),可回滚到任意版本

本文对应代码在 YYB 邮件系统项目的 Phase 11.3 模块中,核心引擎约 530 行 Rust,FFI 绑定约 20 个 C ABI 导出。完整的开发日志和源码见 项目仓库。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部