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}