Skip to main content

asfml_core/
models.rs

1use std::collections::{HashMap, HashSet};
2use std::fmt;
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::{Error, Result};
8
9#[derive(Debug, Clone)]
10pub struct Session {
11    pub ponymail: String,
12}
13
14#[derive(Debug, Clone, Eq, PartialEq)]
15pub struct ListAddress {
16    pub list: String,
17    pub domain: String,
18}
19
20impl ListAddress {
21    pub fn parse(input: &str) -> Result<Self> {
22        let Some((list, domain)) = input.split_once('@') else {
23            return Err(Error::InvalidListAddress(input.to_string()));
24        };
25        if list.is_empty() || domain.is_empty() || domain.contains('@') {
26            return Err(Error::InvalidListAddress(input.to_string()));
27        }
28        Ok(Self {
29            list: list.to_string(),
30            domain: domain.to_string(),
31        })
32    }
33}
34
35impl fmt::Display for ListAddress {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        write!(f, "{}@{}", self.list, self.domain)
38    }
39}
40
41#[derive(Debug, Deserialize)]
42pub struct Preferences {
43    #[serde(default)]
44    pub login: Login,
45    #[serde(default)]
46    pub lists: HashMap<String, HashMap<String, serde_json::Value>>,
47}
48
49impl Preferences {
50    pub fn has_list_access(&self, list: &ListAddress) -> bool {
51        self.lists
52            .get(&list.domain)
53            .is_some_and(|lists| lists.contains_key(&list.list))
54    }
55}
56
57#[derive(Debug, Default, Deserialize)]
58pub struct Login {
59    pub credentials: Option<LoginCredentials>,
60}
61
62#[derive(Debug, Deserialize)]
63pub struct LoginCredentials {
64    #[serde(default)]
65    pub uid: String,
66    pub fullname: Option<String>,
67    pub name: Option<String>,
68    pub email: Option<String>,
69}
70
71#[derive(Debug, Deserialize)]
72pub struct StatsResponse {
73    #[serde(default)]
74    pub emails: Vec<EmailSummary>,
75}
76
77#[derive(Debug, Clone, Deserialize, Serialize)]
78pub struct EmailSummary {
79    pub id: String,
80    #[serde(default)]
81    pub mid: Option<String>,
82    #[serde(default)]
83    pub subject: String,
84    #[serde(default)]
85    pub from: String,
86    #[serde(default)]
87    pub epoch: Option<i64>,
88    #[serde(default, rename = "list")]
89    pub list_name: Option<String>,
90}
91
92impl EmailSummary {
93    pub fn mid(&self) -> &str {
94        self.mid.as_deref().unwrap_or(&self.id)
95    }
96
97    pub fn formatted_date(&self) -> String {
98        format_epoch(self.epoch)
99    }
100}
101
102#[derive(Debug, Clone, Deserialize, Serialize)]
103pub struct Email {
104    pub id: String,
105    #[serde(default)]
106    pub mid: Option<String>,
107    #[serde(default)]
108    pub subject: String,
109    #[serde(default)]
110    pub from: String,
111    #[serde(default)]
112    pub date: Option<String>,
113    #[serde(default)]
114    pub epoch: Option<i64>,
115    #[serde(default, rename = "list")]
116    pub list_name: Option<String>,
117    #[serde(default)]
118    pub body: String,
119    #[serde(default, rename = "message-id")]
120    pub message_id: Option<String>,
121    #[serde(default, rename = "in-reply-to")]
122    pub in_reply_to: Option<String>,
123    #[serde(default)]
124    pub references: Option<String>,
125}
126
127impl Email {
128    pub fn mid(&self) -> &str {
129        self.mid.as_deref().unwrap_or(&self.id)
130    }
131
132    pub fn message_id_key(&self) -> Option<&str> {
133        non_empty(self.message_id.as_deref())
134    }
135
136    pub fn in_reply_to_key(&self) -> Option<&str> {
137        non_empty(self.in_reply_to.as_deref())
138    }
139
140    pub fn formatted_date(&self) -> String {
141        self.date
142            .clone()
143            .unwrap_or_else(|| format_epoch(self.epoch))
144    }
145}
146
147#[derive(Debug, Deserialize, Serialize)]
148pub struct ThreadResponse {
149    pub thread: Email,
150    #[serde(default)]
151    pub emails: Vec<Email>,
152}
153
154impl ThreadResponse {
155    pub fn find_email(&self, mid: &str) -> Option<&Email> {
156        self.emails
157            .iter()
158            .find(|email| email.id == mid || email.mid.as_deref() == Some(mid))
159            .or_else(|| {
160                (self.thread.id == mid || self.thread.mid.as_deref() == Some(mid))
161                    .then_some(&self.thread)
162            })
163    }
164
165    pub fn direct_parent(&self, mid: &str) -> Result<&Email> {
166        let target = self
167            .find_email(mid)
168            .ok_or_else(|| Error::EmailNotFound(mid.to_string()))?;
169        let in_reply_to = target
170            .in_reply_to_key()
171            .ok_or_else(|| Error::ParentNotFound {
172                in_reply_to: String::new(),
173            })?;
174        self.by_message_id()
175            .get(in_reply_to)
176            .copied()
177            .ok_or_else(|| Error::ParentNotFound {
178                in_reply_to: in_reply_to.to_string(),
179            })
180    }
181
182    pub fn root_parent(&self, mid: &str) -> Result<&Email> {
183        let by_message_id = self.by_message_id();
184        let mut current = self
185            .find_email(mid)
186            .ok_or_else(|| Error::EmailNotFound(mid.to_string()))?;
187        let mut seen = HashSet::new();
188
189        loop {
190            let Some(in_reply_to) = current.in_reply_to_key() else {
191                return Ok(current);
192            };
193            if !seen.insert(in_reply_to.to_string()) {
194                return Ok(current);
195            }
196            let Some(parent) = by_message_id.get(in_reply_to).copied() else {
197                if current.id == mid || current.mid.as_deref() == Some(mid) {
198                    return Err(Error::ParentNotFound {
199                        in_reply_to: in_reply_to.to_string(),
200                    });
201                }
202                return Ok(current);
203            };
204            current = parent;
205        }
206    }
207
208    fn by_message_id(&self) -> HashMap<&str, &Email> {
209        let mut map = HashMap::new();
210        if let Some(message_id) = self.thread.message_id_key() {
211            map.insert(message_id, &self.thread);
212        }
213        for email in &self.emails {
214            if let Some(message_id) = email.message_id_key() {
215                map.insert(message_id, email);
216            }
217        }
218        map
219    }
220}
221
222fn non_empty(value: Option<&str>) -> Option<&str> {
223    value.and_then(|value| {
224        let value = value.trim();
225        (!value.is_empty()).then_some(value)
226    })
227}
228
229fn format_epoch(epoch: Option<i64>) -> String {
230    epoch
231        .and_then(|epoch| DateTime::<Utc>::from_timestamp(epoch, 0))
232        .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
233        .unwrap_or_else(|| "-".to_string())
234}
235
236#[cfg(test)]
237mod tests {
238    use crate::error::Error;
239
240    use super::{Email, ListAddress, StatsResponse, ThreadResponse};
241
242    #[test]
243    fn parse_list_address() {
244        let addr = ListAddress::parse("private@opendal.apache.org").unwrap();
245        assert_eq!(addr.list, "private");
246        assert_eq!(addr.domain, "opendal.apache.org");
247    }
248
249    #[test]
250    fn resolve_parent_and_root() {
251        let root = email("root", "<root>", None);
252        let child = email("child", "<child>", Some("<root>"));
253        let grandchild = email("grandchild", "<grandchild>", Some("<child>"));
254        let thread = ThreadResponse {
255            thread: root.clone(),
256            emails: vec![root, child, grandchild],
257        };
258
259        let parent = thread.direct_parent("grandchild").unwrap();
260        assert_eq!(parent.mid(), "child");
261
262        let root = thread.root_parent("grandchild").unwrap();
263        assert_eq!(root.mid(), "root");
264    }
265
266    #[test]
267    fn parse_public_list_fixture() {
268        let stats: StatsResponse =
269            serde_json::from_str(include_str!("../tests/fixtures/opendal_dev_list_30d.json"))
270                .unwrap();
271        assert_eq!(stats.emails.len(), 2);
272        assert_eq!(stats.emails[0].mid(), "qd7m1k6h9hmjt5hdqb28y3vzh561x3bj");
273        assert_eq!(
274            stats.emails[0].subject,
275            "[DISCUSS] Release Apache OpenDAL v0.57.0"
276        );
277    }
278
279    #[test]
280    fn parse_public_search_fixture() {
281        let stats: StatsResponse = serde_json::from_str(include_str!(
282            "../tests/fixtures/opendal_dev_search_release.json"
283        ))
284        .unwrap();
285        assert_eq!(stats.emails.len(), 2);
286        assert_eq!(stats.emails[0].mid(), "6rhj403fyfqoqzyv4201m53gqwkbqrvy");
287        assert!(stats.emails[0].subject.contains("Component Support Tiers"));
288    }
289
290    #[test]
291    fn parse_public_email_fixture() {
292        let email: Email =
293            serde_json::from_str(include_str!("../tests/fixtures/opendal_release_email.json"))
294                .unwrap();
295        assert_eq!(email.mid(), "qd7m1k6h9hmjt5hdqb28y3vzh561x3bj");
296        assert_eq!(
297            email.message_id_key(),
298            Some("<ghd-D_kwDOG1wuLc4AmTjj@gitbox.apache.org>")
299        );
300        assert!(email.body.contains("call for a discussion"));
301    }
302
303    #[test]
304    fn parse_public_thread_fixture() {
305        let thread: ThreadResponse = serde_json::from_str(include_str!(
306            "../tests/fixtures/opendal_release_thread.json"
307        ))
308        .unwrap();
309        assert_eq!(thread.thread.mid(), "qd7m1k6h9hmjt5hdqb28y3vzh561x3bj");
310        assert_eq!(thread.emails.len(), 1);
311        assert_eq!(
312            thread
313                .root_parent("qd7m1k6h9hmjt5hdqb28y3vzh561x3bj")
314                .unwrap()
315                .mid(),
316            "qd7m1k6h9hmjt5hdqb28y3vzh561x3bj"
317        );
318    }
319
320    #[test]
321    fn parse_private_list_snapshot() {
322        let stats: StatsResponse = serde_json::from_str(include_str!(
323            "../tests/fixtures/private_opendal_list_snapshot.json"
324        ))
325        .unwrap();
326        assert_eq!(stats.emails.len(), 5);
327        assert_eq!(stats.emails[0].mid(), "private-list-mid-1");
328        assert_eq!(
329            stats.emails[0].list_name.as_deref(),
330            Some("<private.opendal.apache.org>")
331        );
332    }
333
334    #[test]
335    fn parse_private_search_snapshot() {
336        let stats: StatsResponse = serde_json::from_str(include_str!(
337            "../tests/fixtures/private_opendal_search_vote_snapshot.json"
338        ))
339        .unwrap();
340        assert_eq!(stats.emails.len(), 5);
341        assert_eq!(stats.emails[0].mid(), "private-search-mid-1");
342        assert_eq!(stats.emails[0].subject, "Private search subject 1");
343    }
344
345    #[test]
346    fn parse_private_reply_email_snapshot() {
347        let email: Email = serde_json::from_str(include_str!(
348            "../tests/fixtures/private_opendal_reply_email_snapshot.json"
349        ))
350        .unwrap();
351        assert_eq!(email.mid(), "private-reply-mid-1");
352        assert_eq!(
353            email.in_reply_to_key(),
354            Some("<private-missing-parent@example.invalid>")
355        );
356        assert!(email.body.contains("redacted"));
357    }
358
359    #[test]
360    fn private_reply_thread_snapshot_records_missing_parent() {
361        let thread: ThreadResponse = serde_json::from_str(include_str!(
362            "../tests/fixtures/private_opendal_reply_thread_snapshot.json"
363        ))
364        .unwrap();
365        assert_eq!(thread.emails.len(), 1);
366        let err = thread.direct_parent("private-reply-mid-1").unwrap_err();
367        assert!(matches!(
368            err,
369            Error::ParentNotFound { ref in_reply_to }
370                if in_reply_to == "<private-missing-parent@example.invalid>"
371        ));
372    }
373
374    fn email(id: &str, message_id: &str, in_reply_to: Option<&str>) -> Email {
375        Email {
376            id: id.to_string(),
377            mid: Some(id.to_string()),
378            subject: String::new(),
379            from: String::new(),
380            date: None,
381            epoch: None,
382            list_name: None,
383            body: String::new(),
384            message_id: Some(message_id.to_string()),
385            in_reply_to: in_reply_to.map(ToString::to_string),
386            references: None,
387        }
388    }
389}