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 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}