前言
邮件收着收件箱里几千封邮件是什么感觉?营销邮件堆积如山,工作邮件淹没在订阅通知里,而你真正需要的那封却怎么也找不到。作为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 是归档引擎的"手"。三个关键安全设计:
- 批次上限:
batch_size = 50意味着每次归档最多移动 50 封邮件。这是对 IMAP 服务器负载和用户注意力的双重保护。 - 恢复标记检查:
!self.restored.contains(&c.email_id)防止已恢复的邮件被重复归档。restored是一个HashSet<String>。 - 全量历史记录:每次执行结果都追加到
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),原因是:
- 编译防火墙:邮件系统引擎在 macOS sandbox 中通过
cargo check --lib --locked编译,引入 serde 会增加复杂的 trait 推导链,拖慢增量编译。 - 确定性行为:手写解析器对异常输入有确定的行为——遇到格式错误的 JSON 条件返回空
Vec而非 panic,这在生产环境中是必要的容错。 - 代码透明:可以直接看到解析逻辑的每一步,没有宏黑盒。
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 推荐的运维流程
- 先 Dry-Run:每次引擎启动后先跑一次 dry-run,展示"如果不做操作,当前会归档哪些邮件"
- 人工确认:UI 显示预览列表,用户可勾选排除
- 分批执行:每批 50-100 封,间隔 1 秒,避免 IMAP 服务器过载
- 记录与审计:归档后返回操作记录,支持按邮件 ID 查询历史
- 定期清理:每周调用
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 导出。完整的开发日志和源码见 项目仓库。

发表评论 取消回复