qualia_client_core/
mail_store.rs1use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::PathBuf;
10
11use crate::state::app_meta_dir;
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct StoredMail {
16 pub id: String,
17 pub received_at: u64,
18 pub from_address: String,
19 pub to_address: String,
20 pub mailbox: String,
22 pub subject: String,
23 pub body: String,
24 pub via: String,
26 pub quarantined: bool,
27 pub priority: i8,
28 pub read: bool,
29 #[serde(default)]
30 pub reasons: Vec<String>,
31 #[serde(default)]
33 pub size_bytes: usize,
34}
35
36fn messages_path() -> PathBuf {
37 app_meta_dir().join("mail_messages.json")
38}
39
40fn load_all() -> Vec<StoredMail> {
41 fs::read_to_string(messages_path())
42 .ok()
43 .and_then(|t| serde_json::from_str(&t).ok())
44 .unwrap_or_default()
45}
46
47fn save_all(msgs: &[StoredMail]) -> Result<(), String> {
48 let path = messages_path();
49 if let Some(p) = path.parent() {
50 fs::create_dir_all(p).map_err(|e| e.to_string())?;
51 }
52 let text = serde_json::to_string_pretty(msgs).map_err(|e| e.to_string())?;
53 fs::write(path, text).map_err(|e| e.to_string())
54}
55
56fn now_unix() -> u64 {
57 std::time::SystemTime::now()
58 .duration_since(std::time::UNIX_EPOCH)
59 .map(|d| d.as_secs())
60 .unwrap_or(0)
61}
62
63fn new_id() -> String {
64 let n = now_unix();
66 let r: u32 = rand::random();
67 format!("m-{n:x}-{r:08x}")
68}
69
70pub fn append(msg: StoredMail) -> Result<StoredMail, String> {
72 let mut all = load_all();
73 all.insert(0, msg.clone()); if all.len() > 5_000 {
76 all.truncate(5_000);
77 }
78 save_all(&all)?;
79 Ok(msg)
80}
81
82pub fn store_delivery(
84 from: &str,
85 to: &str,
86 mailbox: &str,
87 subject: &str,
88 body: &str,
89 via: &str,
90 quarantined: bool,
91 priority: i8,
92 reasons: Vec<String>,
93) -> Result<StoredMail, String> {
94 let body = body.to_string();
95 let size_bytes = body.len();
96 let msg = StoredMail {
97 id: new_id(),
98 received_at: now_unix(),
99 from_address: from.to_string(),
100 to_address: to.to_string(),
101 mailbox: mailbox.to_string(),
102 subject: subject.to_string(),
103 body,
104 via: via.to_string(),
105 quarantined,
106 priority,
107 read: false,
108 reasons,
109 size_bytes,
110 };
111 append(msg)
112}
113
114pub fn list(mailbox: Option<&str>, include_quarantine: bool) -> Vec<StoredMail> {
117 load_all()
118 .into_iter()
119 .filter(|m| {
120 if let Some(mb) = mailbox {
121 if !m.mailbox.eq_ignore_ascii_case(mb) && !m.to_address.eq_ignore_ascii_case(mb) {
122 return false;
123 }
124 }
125 if !include_quarantine && m.quarantined {
126 return false;
127 }
128 true
129 })
130 .collect()
131}
132
133pub fn get(id: &str) -> Option<StoredMail> {
134 load_all().into_iter().find(|m| m.id == id)
135}
136
137pub fn set_read(id: &str, read: bool) -> Result<StoredMail, String> {
138 let mut all = load_all();
139 let msg = all
140 .iter_mut()
141 .find(|m| m.id == id)
142 .ok_or_else(|| format!("unknown message '{id}'"))?;
143 msg.read = read;
144 let out = msg.clone();
145 save_all(&all)?;
146 Ok(out)
147}
148
149pub fn delete(id: &str) -> Result<(), String> {
150 let mut all = load_all();
151 let before = all.len();
152 all.retain(|m| m.id != id);
153 if all.len() == before {
154 return Err(format!("unknown message '{id}'"));
155 }
156 save_all(&all)
157}
158
159pub fn counts() -> (usize, usize, usize) {
161 let all = load_all();
162 let total = all.len();
163 let unread = all.iter().filter(|m| !m.read).count();
164 let quarantine = all.iter().filter(|m| m.quarantined).count();
165 (total, unread, quarantine)
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn store_list_read_delete_roundtrip() {
174 let marker = format!("test-subject-{}", now_unix());
177 let stored = store_delivery(
178 "a@b.example",
179 "frontdoor@c.example",
180 "frontdoor@c.example",
181 &marker,
182 "hello body",
183 "exact",
184 false,
185 1,
186 vec!["test".into()],
187 )
188 .expect("store");
189 assert!(!stored.id.is_empty());
190 assert!(list(None, true).iter().any(|m| m.id == stored.id));
191 let got = get(&stored.id).expect("get");
192 assert_eq!(got.subject, marker);
193 set_read(&stored.id, true).expect("read");
194 assert!(get(&stored.id).unwrap().read);
195 delete(&stored.id).expect("delete");
196 assert!(get(&stored.id).is_none());
197 }
198}