Skip to main content

qualia_client_core/
mail_store.rs

1//! Local mail message store — where delivered mail actually lives.
2//!
3//! Inbound mail (local SMTP receiver, inject, or optional IMAP import) is resolved and ruled,
4//! then persisted under `app_meta_dir()/mail_messages.json`. This is the product inbox — not a
5//! pointer at a paid provider.
6
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::PathBuf;
10
11use crate::state::app_meta_dir;
12
13/// A delivered (or quarantined) message in the local inbox.
14#[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    /// Mailbox address that accepted delivery (exact or catchall surface).
21    pub mailbox: String,
22    pub subject: String,
23    pub body: String,
24    /// `exact` | `catchall`
25    pub via: String,
26    pub quarantined: bool,
27    pub priority: i8,
28    pub read: bool,
29    #[serde(default)]
30    pub reasons: Vec<String>,
31    /// Size in bytes of the original body (for UI).
32    #[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    // Compact unique-enough id without pulling uuid into hot API paths unnecessarily.
65    let n = now_unix();
66    let r: u32 = rand::random();
67    format!("m-{n:x}-{r:08x}")
68}
69
70/// Append a delivered message. Returns the stored record.
71pub fn append(msg: StoredMail) -> Result<StoredMail, String> {
72    let mut all = load_all();
73    all.insert(0, msg.clone()); // newest first
74                                // Soft cap — keep last 5_000 messages so the file stays bounded.
75    if all.len() > 5_000 {
76        all.truncate(5_000);
77    }
78    save_all(&all)?;
79    Ok(msg)
80}
81
82/// Build + append from delivery fields.
83pub 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
114/// List messages, newest first. `mailbox` filters by accepting mailbox when set.
115/// `include_quarantine` includes quarantined messages (default true for full inbox; UI can filter).
116pub 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
159/// Counts for the UI badge.
160pub 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        // Isolate: only exercises pure helpers that don't need a special dir if app_meta works;
175        // use unique subject markers and clean up.
176        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}