Skip to main content

agent_first_mail/store/
purge.rs

1use super::*;
2
3#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4enum PurgeDisposition {
5    Spam,
6    Trash,
7    Deleted,
8}
9
10impl PurgeDisposition {
11    fn status(self) -> MessageStatus {
12        match self {
13            Self::Spam => MessageStatus::Spam,
14            Self::Trash => MessageStatus::Trashed,
15            Self::Deleted => MessageStatus::DeletedRemote,
16        }
17    }
18
19    fn target(self) -> &'static str {
20        match self {
21            Self::Spam => "spam",
22            Self::Trash => "trash",
23            Self::Deleted => "deleted",
24        }
25    }
26}
27
28#[derive(Default)]
29struct PurgeTotals {
30    purged_message_ids: Vec<String>,
31    purged_spam_count: usize,
32    purged_trash_count: usize,
33    purged_deleted_count: usize,
34    skipped_referenced_message_ids: Vec<String>,
35    skipped_recent_message_ids: Vec<String>,
36}
37
38impl PurgeTotals {
39    fn record_purged(&mut self, target: PurgeDisposition, message_id: String) {
40        match target {
41            PurgeDisposition::Spam => self.purged_spam_count += 1,
42            PurgeDisposition::Trash => self.purged_trash_count += 1,
43            PurgeDisposition::Deleted => self.purged_deleted_count += 1,
44        }
45        self.purged_message_ids.push(message_id);
46    }
47}
48
49impl Workspace {
50    pub fn purge_spam(&self, older_than_days: u64) -> Result<Value> {
51        self.purge_dispositions(&[PurgeDisposition::Spam], older_than_days)
52    }
53
54    pub fn purge_trash(&self, older_than_days: u64) -> Result<Value> {
55        self.purge_dispositions(&[PurgeDisposition::Trash], older_than_days)
56    }
57
58    pub fn purge_deleted(&self, older_than_days: u64) -> Result<Value> {
59        self.purge_dispositions(&[PurgeDisposition::Deleted], older_than_days)
60    }
61
62    pub fn purge_discards(&self, older_than_days: u64) -> Result<Value> {
63        self.purge_dispositions(
64            &[
65                PurgeDisposition::Spam,
66                PurgeDisposition::Trash,
67                PurgeDisposition::Deleted,
68            ],
69            older_than_days,
70        )
71    }
72
73    fn purge_dispositions(
74        &self,
75        targets: &[PurgeDisposition],
76        older_than_days: u64,
77    ) -> Result<Value> {
78        self.require_workspace()?;
79        let cutoff = Utc::now() - Duration::days(older_than_days as i64);
80        let targets_by_status = targets
81            .iter()
82            .copied()
83            .map(|target| (target.status(), target))
84            .collect::<BTreeMap<_, _>>();
85        let mut totals = PurgeTotals::default();
86
87        for path in message_json_paths(&self.root)? {
88            let Some(message_id) = path.file_stem().and_then(|stem| stem.to_str()) else {
89                continue;
90            };
91            let message = self.read_message_by_id(message_id)?;
92            let status = MessageStatus::parse(&message.workspace.status)?;
93            let Some(target) = targets_by_status.get(&status).copied() else {
94                continue;
95            };
96            let age_time = self.message_purge_age_time(&message)?;
97            if age_time > cutoff {
98                totals
99                    .skipped_recent_message_ids
100                    .push(message.message_id.clone());
101                continue;
102            }
103            if self.message_id_is_referenced(&message.message_id)? {
104                totals
105                    .skipped_referenced_message_ids
106                    .push(message.message_id.clone());
107                continue;
108            }
109            let message_id = message.message_id.clone();
110            purge_message_artifacts(&self.root, &message_id)?;
111            self.clear_message_disposition(target.status(), &message_id)?;
112            totals.record_purged(target, message_id);
113        }
114
115        let dispositions = self.refresh_disposition_views()?;
116        let mut result = json!({
117            "code": "purged",
118            "target": purge_target_name(targets),
119            "targets": targets.iter().map(|target| target.target()).collect::<Vec<_>>(),
120            "older_than_days": older_than_days,
121            "purged_count": totals.purged_message_ids.len(),
122            "purged_message_ids": totals.purged_message_ids,
123            "purged_spam_count": totals.purged_spam_count,
124            "purged_trash_count": totals.purged_trash_count,
125            "purged_deleted_count": totals.purged_deleted_count,
126            "skipped_referenced_count": totals.skipped_referenced_message_ids.len(),
127            "skipped_referenced_message_ids": totals.skipped_referenced_message_ids,
128            "skipped_recent_count": totals.skipped_recent_message_ids.len(),
129            "skipped_recent_message_ids": totals.skipped_recent_message_ids,
130        });
131        merge_disposition_refresh_into_purge(&mut result, &dispositions);
132        Ok(result)
133    }
134
135    fn message_purge_age_time(&self, message: &MessageFile) -> Result<DateTime<Utc>> {
136        let status = MessageStatus::parse(&message.workspace.status)?;
137        let value = self
138            .disposition_state_for_message(&message.message_id)?
139            .and_then(|(disposition_status, added_rfc3339)| {
140                if disposition_status == status {
141                    Some(added_rfc3339)
142                } else {
143                    None
144                }
145            })
146            .or_else(|| message.received_rfc3339.clone())
147            .or_else(|| message.sent_rfc3339.clone())
148            .unwrap_or_else(now_rfc3339);
149        Ok(DateTime::parse_from_rfc3339(&value)
150            .map(|time| time.with_timezone(&Utc))
151            .unwrap_or_else(|_| Utc::now()))
152    }
153}
154
155fn purge_target_name(targets: &[PurgeDisposition]) -> &'static str {
156    if targets == [PurgeDisposition::Spam] {
157        "spam"
158    } else if targets == [PurgeDisposition::Trash] {
159        "trash"
160    } else if targets == [PurgeDisposition::Deleted] {
161        "deleted"
162    } else {
163        "discards"
164    }
165}
166
167fn merge_disposition_refresh_into_purge(purge: &mut Value, dispositions: &Value) {
168    let Some(purge_obj) = purge.as_object_mut() else {
169        return;
170    };
171    let Some(disposition_obj) = dispositions.as_object() else {
172        return;
173    };
174    for key in [
175        "spam_count",
176        "spam_written_count",
177        "stale_spam_removed_count",
178        "trash_count",
179        "trash_written_count",
180        "stale_trash_removed_count",
181        "deleted_count",
182        "deleted_written_count",
183        "stale_deleted_removed_count",
184    ] {
185        if let Some(value) = disposition_obj.get(key) {
186            purge_obj.insert(key.to_string(), value.clone());
187        }
188    }
189}