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 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}