agent_first_mail/store/
purge.rs1use 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 message = read_message(&path)?;
89 let status = MessageStatus::parse(&message.workspace.status)?;
90 let Some(target) = targets_by_status.get(&status).copied() else {
91 continue;
92 };
93 let age_time = self.message_purge_age_time(&message)?;
94 if age_time > cutoff {
95 totals
96 .skipped_recent_message_ids
97 .push(message.message_id.clone());
98 continue;
99 }
100 if self.message_id_is_referenced(&message.message_id)? {
101 totals
102 .skipped_referenced_message_ids
103 .push(message.message_id.clone());
104 continue;
105 }
106 let message_id = message.message_id.clone();
107 purge_message_artifacts(&self.root, &message_id)?;
108 totals.record_purged(target, message_id);
109 }
110
111 let dispositions = self.refresh_disposition_views()?;
112 let mut result = json!({
113 "code": "purged",
114 "target": purge_target_name(targets),
115 "targets": targets.iter().map(|target| target.target()).collect::<Vec<_>>(),
116 "older_than_days": older_than_days,
117 "purged_count": totals.purged_message_ids.len(),
118 "purged_message_ids": totals.purged_message_ids,
119 "purged_spam_count": totals.purged_spam_count,
120 "purged_trash_count": totals.purged_trash_count,
121 "purged_deleted_count": totals.purged_deleted_count,
122 "skipped_referenced_count": totals.skipped_referenced_message_ids.len(),
123 "skipped_referenced_message_ids": totals.skipped_referenced_message_ids,
124 "skipped_recent_count": totals.skipped_recent_message_ids.len(),
125 "skipped_recent_message_ids": totals.skipped_recent_message_ids,
126 });
127 merge_disposition_refresh_into_purge(&mut result, &dispositions);
128 Ok(result)
129 }
130
131 fn message_purge_age_time(&self, message: &MessageFile) -> Result<DateTime<Utc>> {
132 let value = message_state_updated_rfc3339(&self.root, &message.message_id)?
133 .or_else(|| message.received_rfc3339.clone())
134 .or_else(|| message.sent_rfc3339.clone())
135 .unwrap_or_else(now_rfc3339);
136 Ok(DateTime::parse_from_rfc3339(&value)
137 .map(|time| time.with_timezone(&Utc))
138 .unwrap_or_else(|_| Utc::now()))
139 }
140}
141
142fn purge_target_name(targets: &[PurgeDisposition]) -> &'static str {
143 if targets == [PurgeDisposition::Spam] {
144 "spam"
145 } else if targets == [PurgeDisposition::Trash] {
146 "trash"
147 } else if targets == [PurgeDisposition::Deleted] {
148 "deleted"
149 } else {
150 "discards"
151 }
152}
153
154fn merge_disposition_refresh_into_purge(purge: &mut Value, dispositions: &Value) {
155 let Some(purge_obj) = purge.as_object_mut() else {
156 return;
157 };
158 let Some(disposition_obj) = dispositions.as_object() else {
159 return;
160 };
161 for key in [
162 "spam_count",
163 "spam_written_count",
164 "stale_spam_removed_count",
165 "trash_count",
166 "trash_written_count",
167 "stale_trash_removed_count",
168 "deleted_count",
169 "deleted_written_count",
170 "stale_deleted_removed_count",
171 ] {
172 if let Some(value) = disposition_obj.get(key) {
173 purge_obj.insert(key.to_string(), value.clone());
174 }
175 }
176}