Skip to main content

ecr_core/
message.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeSet;
3use std::fmt;
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
6pub struct MessageId(pub String);
7
8impl MessageId {
9    pub fn as_str(&self) -> &str {
10        &self.0
11    }
12
13    pub fn query(&self) -> String {
14        format!("id:\"{}\"", self.0)
15    }
16}
17
18impl fmt::Display for MessageId {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        f.write_str(&self.0)
21    }
22}
23
24impl From<&str> for MessageId {
25    fn from(s: &str) -> Self {
26        MessageId(s.to_string())
27    }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
31pub struct ThreadId(pub String);
32
33impl ThreadId {
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37
38    pub fn query(&self) -> String {
39        format!("thread:\"{}\"", self.0)
40    }
41}
42
43impl fmt::Display for ThreadId {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str(&self.0)
46    }
47}
48
49impl From<&str> for ThreadId {
50    fn from(s: &str) -> Self {
51        ThreadId(s.to_string())
52    }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Address {
57    pub name: Option<String>,
58    pub email: String,
59}
60
61impl Address {
62    pub fn new(name: Option<String>, email: impl Into<String>) -> Self {
63        Self {
64            name: name.filter(|n| !n.trim().is_empty()),
65            email: email.into(),
66        }
67    }
68
69    pub fn display(&self) -> &str {
70        self.name.as_deref().unwrap_or(&self.email)
71    }
72}
73
74impl fmt::Display for Address {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match &self.name {
77            Some(name) => write!(f, "{name} <{}>", self.email),
78            None => f.write_str(&self.email),
79        }
80    }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct ThreadSummary {
85    pub id: ThreadId,
86    pub subject: String,
87    pub authors: Vec<String>,
88    pub timestamp: i64,
89    pub date_relative: String,
90    pub matched: usize,
91    pub total: usize,
92    pub tags: BTreeSet<String>,
93    pub newest_message: Option<MessageId>,
94}
95
96impl ThreadSummary {
97    pub fn is_unread(&self) -> bool {
98        self.tags.contains("unread")
99    }
100
101    pub fn is_flagged(&self) -> bool {
102        self.tags.contains("flagged")
103    }
104
105    pub fn has_attachment(&self) -> bool {
106        self.tags.contains("attachment")
107    }
108
109    pub fn account(&self, known: &[&str]) -> Option<String> {
110        known
111            .iter()
112            .find(|a| self.tags.contains(**a))
113            .map(|a| a.to_string())
114    }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct Thread {
119    pub id: ThreadId,
120    pub subject: String,
121    pub messages: Vec<Message>,
122}
123
124impl Thread {
125    pub fn newest(&self) -> Option<&Message> {
126        self.messages.iter().max_by_key(|m| m.timestamp)
127    }
128
129    pub fn unread_count(&self) -> usize {
130        self.messages.iter().filter(|m| m.is_unread()).count()
131    }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct Message {
136    pub id: MessageId,
137    pub thread_id: ThreadId,
138    pub subject: String,
139    pub from: Vec<Address>,
140    pub to: Vec<Address>,
141    pub cc: Vec<Address>,
142    pub bcc: Vec<Address>,
143    pub reply_to: Vec<Address>,
144    pub date: String,
145    pub timestamp: i64,
146    pub tags: BTreeSet<String>,
147    pub in_reply_to: Option<String>,
148    pub references: Vec<String>,
149    pub parts: Vec<PartMeta>,
150    pub excluded: bool,
151}
152
153impl Message {
154    pub fn is_unread(&self) -> bool {
155        self.tags.contains("unread")
156    }
157
158    pub fn attachments(&self) -> impl Iterator<Item = &PartMeta> {
159        self.parts
160            .iter()
161            .filter(|p| p.disposition == Disposition::Attachment)
162    }
163
164    pub fn inline_parts(&self) -> impl Iterator<Item = &PartMeta> {
165        self.parts.iter().filter(|p| p.content_id.is_some())
166    }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum Disposition {
172    Inline,
173    Attachment,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
177pub struct PartId(pub u32);
178
179impl fmt::Display for PartId {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        write!(f, "{}", self.0)
182    }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186pub struct PartMeta {
187    pub id: PartId,
188    pub content_type: String,
189    pub filename: Option<String>,
190    pub size: usize,
191    pub disposition: Disposition,
192    pub content_id: Option<String>,
193}
194
195impl PartMeta {
196    pub fn is_image(&self) -> bool {
197        self.content_type.starts_with("image/")
198    }
199
200    pub fn display_name(&self) -> String {
201        self.filename
202            .clone()
203            .unwrap_or_else(|| format!("part-{}", self.id))
204    }
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct Part {
209    pub meta: PartMeta,
210    pub bytes: Vec<u8>,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename_all = "snake_case")]
215pub enum BodyFormat {
216    Text,
217    Html,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct Body {
222    pub format: BodyFormat,
223    pub content: String,
224    pub remote_resources_blocked: usize,
225    /// Whether the message carries a real HTML part, so the client knows
226    /// whether offering to switch to it means anything.
227    pub has_html: bool,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct TagOp {
232    pub id: MessageId,
233    pub add: Vec<String>,
234    pub remove: Vec<String>,
235}
236
237impl TagOp {
238    pub fn new(id: MessageId) -> Self {
239        Self {
240            id,
241            add: Vec::new(),
242            remove: Vec::new(),
243        }
244    }
245
246    pub fn adding(mut self, tag: impl Into<String>) -> Self {
247        self.add.push(tag.into());
248        self
249    }
250
251    pub fn removing(mut self, tag: impl Into<String>) -> Self {
252        self.remove.push(tag.into());
253        self
254    }
255
256    pub fn is_empty(&self) -> bool {
257        self.add.is_empty() && self.remove.is_empty()
258    }
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct Query {
263    pub text: String,
264    pub limit: usize,
265    pub offset: usize,
266}
267
268impl Query {
269    pub const DEFAULT_LIMIT: usize = 50;
270
271    pub fn new(text: impl Into<String>) -> Self {
272        Self {
273            text: text.into(),
274            limit: Self::DEFAULT_LIMIT,
275            offset: 0,
276        }
277    }
278
279    pub fn limit(mut self, limit: usize) -> Self {
280        self.limit = limit;
281        self
282    }
283
284    pub fn offset(mut self, offset: usize) -> Self {
285        self.offset = offset;
286        self
287    }
288
289    pub fn is_empty(&self) -> bool {
290        self.text.trim().is_empty()
291    }
292
293    pub fn effective_text(&self) -> &str {
294        if self.is_empty() {
295            "*"
296        } else {
297            self.text.trim()
298        }
299    }
300}
301
302#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
303pub struct SyncReport {
304    pub channels: Vec<String>,
305    pub new_messages: usize,
306    pub duration_ms: u64,
307    pub warnings: Vec<String>,
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn ids_quote_themselves_for_notmuch() {
316        assert_eq!(MessageId::from("a@b.c").query(), "id:\"a@b.c\"");
317        assert_eq!(
318            ThreadId::from("0000000000002899").query(),
319            "thread:\"0000000000002899\""
320        );
321    }
322
323    #[test]
324    fn an_empty_query_becomes_the_match_all_query() {
325        assert_eq!(Query::new("   ").effective_text(), "*");
326        assert_eq!(Query::new("tag:inbox").effective_text(), "tag:inbox");
327    }
328
329    #[test]
330    fn address_display_prefers_the_name() {
331        let named = Address::new(Some("Alice".into()), "a@b.c");
332        let bare = Address::new(None, "a@b.c");
333
334        assert_eq!(named.display(), "Alice");
335        assert_eq!(bare.display(), "a@b.c");
336        assert_eq!(named.to_string(), "Alice <a@b.c>");
337        assert_eq!(bare.to_string(), "a@b.c");
338    }
339
340    #[test]
341    fn a_blank_display_name_is_not_a_name() {
342        assert_eq!(Address::new(Some("  ".into()), "a@b.c").display(), "a@b.c");
343    }
344
345    #[test]
346    fn tag_ops_build_up_and_report_emptiness() {
347        let op = TagOp::new(MessageId::from("x"));
348        assert!(op.is_empty());
349
350        let op = op.adding("inbox").removing("unread");
351        assert_eq!(op.add, vec!["inbox"]);
352        assert_eq!(op.remove, vec!["unread"]);
353        assert!(!op.is_empty());
354    }
355
356    fn summary(tags: &[&str]) -> ThreadSummary {
357        ThreadSummary {
358            id: ThreadId::from("t"),
359            subject: "s".into(),
360            authors: vec!["a".into()],
361            timestamp: 0,
362            date_relative: "now".into(),
363            matched: 1,
364            total: 1,
365            tags: tags.iter().map(|t| t.to_string()).collect(),
366            newest_message: None,
367        }
368    }
369
370    #[test]
371    fn thread_flags_read_off_tags() {
372        let s = summary(&["inbox", "unread", "attachment"]);
373        assert!(s.is_unread());
374        assert!(s.has_attachment());
375        assert!(!s.is_flagged());
376    }
377
378    #[test]
379    fn thread_account_comes_from_the_account_tag() {
380        let s = summary(&["inbox", "team"]);
381        assert_eq!(
382            s.account(&["main", "work", "personal", "team"]).as_deref(),
383            Some("team")
384        );
385        assert_eq!(summary(&["inbox"]).account(&["main"]), None);
386    }
387}