1use std::io;
2use std::path::Path;
3
4use rusqlite::{Connection, OptionalExtension, params};
5use rusqlite_migration::{M, Migrations};
6use serde::{Deserialize, Serialize};
7
8use crate::paths::Paths;
9
10const MIGRATIONS: &[M<'static>] = &[
12 M::up(include_str!("../migrations/001-initial/up.sql")),
13 M::up(include_str!("../migrations/002-initial-uid-next/up.sql")),
14 M::up(include_str!("../migrations/003-notified-uid/up.sql")),
15 M::up(include_str!(
16 "../migrations/004-drop-message-id-index/up.sql"
17 )),
18 M::up(include_str!("../migrations/005-folder-delimiter/up.sql")),
19 M::up(include_str!("../migrations/006-flag-sync/up.sql")),
20 M::up(include_str!("../migrations/007-rule-definition/up.sql")),
21 M::up(include_str!("../migrations/008-has-attachment/up.sql")),
22 M::up(include_str!("../migrations/009-folder-thread-index/up.sql")),
23 M::up(include_str!("../migrations/010-unread-index/up.sql")),
24];
25
26#[derive(Debug, thiserror::Error)]
27pub enum StoreError {
28 #[error("database: {0}")]
29 Sqlite(#[from] rusqlite::Error),
30 #[error("database migration: {0}")]
31 Migration(#[from] rusqlite_migration::Error),
32 #[error("database row: {0}")]
33 Serde(#[from] serde_rusqlite::Error),
34 #[error(transparent)]
35 Io(#[from] io::Error),
36 #[error("json: {0}")]
37 Json(#[from] serde_json::Error),
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct Folder {
42 pub name: String,
43 pub uidvalidity: u32,
44 pub last_uid: u32,
45 pub special_use: Option<String>,
46 #[serde(default)]
47 pub delimiter: Option<String>,
48}
49
50#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
51pub struct Message {
52 pub folder: String,
53 pub uid: u32,
54 pub message_id: Option<String>,
55 pub from_addr: Option<String>,
56 pub to_addr: Option<String>,
57 pub cc_addr: Option<String>,
58 pub delivered_to: Option<String>,
59 pub in_reply_to: Option<String>,
60 pub refs: Option<String>,
61 pub thread_id: String,
62 pub subject: Option<String>,
63 pub date: Option<i64>,
64 pub internaldate: i64,
65 pub flags: String,
66 pub size: Option<u32>,
67 #[serde(skip_serializing, deserialize_with = "deserialize_blob")]
68 pub headers: Vec<u8>,
69 pub body_text: Option<String>,
70}
71
72pub fn has_flag(flags: &str, flag: &str) -> bool {
74 flags.split_whitespace().any(|f| f == flag)
75}
76
77pub fn has_flag_ignore_case(flags: &str, flag: &str) -> bool {
79 flags
80 .split_whitespace()
81 .any(|f| f.eq_ignore_ascii_case(flag))
82}
83
84impl Message {
85 pub fn is_seen(&self) -> bool {
86 has_flag(&self.flags, "\\Seen")
87 }
88
89 pub fn is_flagged(&self) -> bool {
90 has_flag(&self.flags, "\\Flagged")
91 }
92
93 pub fn thread_depth(&self) -> usize {
95 let refs = self
96 .refs
97 .as_deref()
98 .map_or(0, |r| r.split_whitespace().count());
99 refs.max(usize::from(self.in_reply_to.is_some()))
100 }
101}
102
103#[derive(Debug, Clone, PartialEq)]
105pub struct MessageSummary {
106 pub uid: u32,
107 pub from: String,
108 pub to: String,
109 pub subject: String,
110 pub date: i64,
111 pub flags: String,
112}
113
114impl MessageSummary {
115 pub fn is_seen(&self) -> bool {
116 has_flag(&self.flags, "\\Seen")
117 }
118
119 pub fn is_flagged(&self) -> bool {
120 has_flag(&self.flags, "\\Flagged")
121 }
122}
123
124#[derive(Debug, Clone, PartialEq)]
126pub struct ThreadSummary {
127 pub thread_id: String,
128 pub latest: MessageSummary,
129 pub count: u32,
130 pub unread: bool,
131 pub flagged: bool,
132}
133
134fn summary_from_row(row: &rusqlite::Row, offset: usize) -> rusqlite::Result<MessageSummary> {
135 Ok(MessageSummary {
136 uid: row.get(offset)?,
137 from: row.get(offset + 1)?,
138 to: row.get(offset + 2)?,
139 subject: row.get(offset + 3)?,
140 date: row.get(offset + 4)?,
141 flags: row.get(offset + 5)?,
142 })
143}
144
145const UNSEEN: &str = r"instr(' ' || flags || ' ', ' \Seen ') = 0";
147const FLAGGED: &str = r"instr(' ' || flags || ' ', ' \Flagged ') > 0";
148
149fn unread_count_sql() -> String {
152 format!(
153 "SELECT COUNT(*) FROM messages INDEXED BY messages_unread WHERE folder = ?1 AND {UNSEEN}"
154 )
155}
156
157#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
159pub struct Filter {
160 pub unread: bool,
161 pub flagged: bool,
162 pub attachments: bool,
164}
165
166impl Filter {
167 fn sql(self) -> String {
169 let conditions = [
170 (self.unread, UNSEEN),
171 (self.flagged, FLAGGED),
172 (self.attachments, "has_attachment"),
173 ];
174 let set: Vec<&str> = conditions
175 .iter()
176 .filter(|(on, _)| *on)
177 .map(|(_, sql)| *sql)
178 .collect();
179 if set.is_empty() {
180 "1".into()
181 } else {
182 format!("({})", set.join(" AND "))
183 }
184 }
185}
186
187const SUMMARY_COLUMNS: &str = "uid, COALESCE(from_addr, ''), COALESCE(to_addr, ''), COALESCE(subject, ''), internaldate, flags";
188
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct LogEntry {
191 #[serde(default)]
192 pub id: i64,
193 pub at: i64,
194 pub rule_name: String,
195 pub folder: String,
196 pub uid: u32,
197 pub message_id: Option<String>,
198 pub subject: Option<String>,
199 pub action: String,
200 pub trash_file: Option<String>,
201}
202
203fn deserialize_blob<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
205 struct BlobVisitor;
206 impl<'de> serde::de::Visitor<'de> for BlobVisitor {
207 type Value = Vec<u8>;
208 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
209 f.write_str("bytes")
210 }
211 fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<Vec<u8>, E> {
212 Ok(v.to_vec())
213 }
214 fn visit_byte_buf<E: serde::de::Error>(self, v: Vec<u8>) -> Result<Vec<u8>, E> {
215 Ok(v)
216 }
217 }
218 d.deserialize_byte_buf(BlobVisitor)
219}
220
221pub struct Store {
222 conn: Connection,
223}
224
225const MESSAGE_COLUMNS: &str = "folder, uid, message_id, from_addr, to_addr, cc_addr, delivered_to, in_reply_to, refs, thread_id, subject, date, internaldate, flags, size, headers, body_text";
226
227impl Store {
228 pub fn migrations() -> Migrations<'static> {
229 Migrations::from_slice(MIGRATIONS)
230 }
231
232 pub fn open(path: &Path) -> Result<Store, StoreError> {
233 if let Some(dir) = path.parent() {
234 std::fs::create_dir_all(dir)?;
235 }
236 let conn = Connection::open(path)?;
237 Store::init(conn)
238 }
239
240 pub fn open_account(paths: &Paths, account: &str) -> Result<Store, StoreError> {
242 paths.ensure_account(account)?;
243 Store::open(&paths.mail_db(account))
244 }
245
246 pub fn open_in_memory() -> Result<Store, StoreError> {
247 Store::init(Connection::open_in_memory()?)
248 }
249
250 fn init(mut conn: Connection) -> Result<Store, StoreError> {
251 conn.pragma_update_and_check(None, "journal_mode", "WAL", |_| Ok(()))?;
252 conn.pragma_update(None, "synchronous", "NORMAL")?;
253 conn.pragma_update(None, "foreign_keys", "ON")?;
254 conn.busy_timeout(std::time::Duration::from_secs(5))?;
256 Store::migrations().to_latest(&mut conn)?;
257 Ok(Store { conn })
258 }
259
260 pub fn transaction<T, E: From<StoreError>>(
262 &self,
263 f: impl FnOnce() -> Result<T, E>,
264 ) -> Result<T, E> {
265 let tx = self
266 .conn
267 .unchecked_transaction()
268 .map_err(StoreError::from)?;
269 let out = f()?;
270 tx.commit().map_err(StoreError::from)?;
271 Ok(out)
272 }
273
274 pub fn folders(&self) -> Result<Vec<Folder>, StoreError> {
275 let mut stmt = self.conn.prepare(
276 "SELECT name, uidvalidity, last_uid, special_use, delimiter FROM folders ORDER BY name",
277 )?;
278 let rows = serde_rusqlite::from_rows::<Folder>(stmt.query([])?);
279 Ok(rows.collect::<Result<Vec<_>, _>>()?)
280 }
281
282 pub fn folder(&self, name: &str) -> Result<Option<Folder>, StoreError> {
283 let mut stmt = self.conn.prepare(
284 "SELECT name, uidvalidity, last_uid, special_use, delimiter FROM folders WHERE name = ?1",
285 )?;
286 let mut rows = stmt.query(params![name])?;
287 match rows.next()? {
288 Some(row) => Ok(Some(serde_rusqlite::from_row(row)?)),
289 None => Ok(None),
290 }
291 }
292
293 pub fn upsert_folder(&self, folder: &Folder) -> Result<(), StoreError> {
294 self.conn.execute(
295 "INSERT INTO folders (name, uidvalidity, last_uid, special_use, delimiter) VALUES (?1, ?2, ?3, ?4, ?5)
296 ON CONFLICT(name) DO UPDATE SET uidvalidity = excluded.uidvalidity, last_uid = excluded.last_uid,
297 special_use = excluded.special_use, delimiter = COALESCE(excluded.delimiter, folders.delimiter)",
298 params![folder.name, folder.uidvalidity, folder.last_uid, folder.special_use, folder.delimiter],
299 )?;
300 Ok(())
301 }
302
303 pub fn initial_uid_next(&self, folder: &str) -> Result<u32, StoreError> {
305 self.folder_uid(folder, "initial_uid_next")
306 }
307
308 pub fn set_initial_uid_next(&self, folder: &str, uid_next: u32) -> Result<(), StoreError> {
309 self.set_folder_uid(folder, "initial_uid_next", uid_next)
310 }
311
312 pub fn rules_uid(&self, folder: &str) -> Result<u32, StoreError> {
314 self.folder_uid(folder, "rules_uid")
315 }
316
317 pub fn set_rules_uid(&self, folder: &str, uid: u32) -> Result<(), StoreError> {
318 self.set_folder_uid(folder, "rules_uid", uid)
319 }
320
321 pub fn notified_uid(&self, folder: &str) -> Result<u32, StoreError> {
323 self.folder_uid(folder, "notified_uid")
324 }
325
326 pub fn set_notified_uid(&self, folder: &str, uid: u32) -> Result<(), StoreError> {
327 self.set_folder_uid(folder, "notified_uid", uid)
328 }
329
330 pub fn flags_synced(&self, folder: &str) -> Result<(u64, i64), StoreError> {
332 Ok(self
333 .conn
334 .query_row(
335 "SELECT highest_modseq, flags_synced_at FROM folders WHERE name = ?1",
336 params![folder],
337 |r| Ok((r.get::<_, i64>(0)? as u64, r.get(1)?)),
338 )
339 .optional()?
340 .unwrap_or((0, 0)))
341 }
342
343 pub fn set_flags_synced(
344 &self,
345 folder: &str,
346 highest_modseq: u64,
347 at: i64,
348 ) -> Result<(), StoreError> {
349 self.conn.execute(
351 "UPDATE folders SET highest_modseq = ?2, flags_synced_at = ?3 WHERE name = ?1",
352 params![folder, highest_modseq as i64, at],
353 )?;
354 Ok(())
355 }
356
357 fn folder_uid(&self, folder: &str, column: &'static str) -> Result<u32, StoreError> {
359 Ok(self
360 .conn
361 .query_row(
362 &format!("SELECT {column} FROM folders WHERE name = ?1"),
363 params![folder],
364 |r| r.get(0),
365 )
366 .optional()?
367 .unwrap_or(0))
368 }
369
370 fn set_folder_uid(
371 &self,
372 folder: &str,
373 column: &'static str,
374 uid: u32,
375 ) -> Result<(), StoreError> {
376 self.conn.execute(
377 &format!("UPDATE folders SET {column} = ?2 WHERE name = ?1"),
378 params![folder, uid],
379 )?;
380 Ok(())
381 }
382
383 pub fn reset_folder(&self, name: &str, uidvalidity: u32) -> Result<(), StoreError> {
384 self.conn
385 .execute("DELETE FROM messages WHERE folder = ?1", params![name])?;
386 self.conn.execute(
387 "UPDATE folders SET uidvalidity = ?2, last_uid = 0 WHERE name = ?1",
388 params![name, uidvalidity],
389 )?;
390 Ok(())
391 }
392
393 pub fn remove_folder(&self, name: &str) -> Result<(), StoreError> {
395 self.conn
396 .execute("DELETE FROM folders WHERE name = ?1", params![name])?;
397 Ok(())
398 }
399
400 pub fn insert_message(&self, m: &Message) -> Result<(), StoreError> {
401 let sql = format!(
402 "INSERT OR IGNORE INTO messages ({MESSAGE_COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)"
403 );
404 self.conn.prepare_cached(&sql)?.execute(params![
405 m.folder,
406 m.uid,
407 m.message_id,
408 m.from_addr,
409 m.to_addr,
410 m.cc_addr,
411 m.delivered_to,
412 m.in_reply_to,
413 m.refs,
414 m.thread_id,
415 m.subject,
416 m.date,
417 m.internaldate,
418 m.flags,
419 m.size,
420 m.headers,
421 m.body_text
422 ])?;
423 Ok(())
424 }
425
426 pub fn update_flags(&self, folder: &str, uid: u32, flags: &str) -> Result<(), StoreError> {
427 self.conn
428 .prepare_cached(
429 "UPDATE messages SET flags = ?3 WHERE folder = ?1 AND uid = ?2 AND flags IS NOT ?3",
430 )?
431 .execute(params![folder, uid, flags])?;
432 Ok(())
433 }
434
435 pub fn remove_message(&self, folder: &str, uid: u32) -> Result<(), StoreError> {
436 self.conn.execute(
437 "DELETE FROM messages WHERE folder = ?1 AND uid = ?2",
438 params![folder, uid],
439 )?;
440 Ok(())
441 }
442
443 pub fn remove_missing(
445 &self,
446 folder: &str,
447 upto_uid: u32,
448 present: &[u32],
449 ) -> Result<usize, StoreError> {
450 let present = serde_json::to_string(present)?;
451 Ok(self.conn.execute(
452 "DELETE FROM messages
453 WHERE folder = ?1 AND uid <= ?2 AND uid NOT IN (SELECT value FROM json_each(?3))",
454 params![folder, upto_uid, present],
455 )?)
456 }
457
458 pub fn messages(
459 &self,
460 folder: &str,
461 limit: u32,
462 filter: Filter,
463 ) -> Result<Vec<Message>, StoreError> {
464 self.query_messages(
465 &format!(
466 "WHERE folder = ?1 AND {} ORDER BY internaldate DESC, uid DESC LIMIT ?2",
467 filter.sql()
468 ),
469 params![folder, limit],
470 )
471 }
472
473 fn query_messages(
475 &self,
476 tail: &str,
477 params: impl rusqlite::Params,
478 ) -> Result<Vec<Message>, StoreError> {
479 let mut stmt = self
480 .conn
481 .prepare_cached(&format!("SELECT {MESSAGE_COLUMNS} FROM messages {tail}"))?;
482 let rows = serde_rusqlite::from_rows::<Message>(stmt.query(params)?);
483 Ok(rows.collect::<Result<Vec<_>, _>>()?)
484 }
485
486 pub fn threads(
489 &self,
490 folder: &str,
491 limit: u32,
492 filter: Filter,
493 ) -> Result<Vec<Vec<Message>>, StoreError> {
494 let ids: Vec<String> = {
495 let mut stmt = self.conn.prepare(&format!(
496 "SELECT thread_id FROM messages WHERE folder = ?1 GROUP BY thread_id HAVING SUM({}) > 0
497 ORDER BY MAX(internaldate) DESC, MAX(uid) DESC LIMIT ?2",
498 filter.sql()
499 ))?;
500 let rows = stmt.query_map(params![folder, limit], |r| r.get(0))?;
501 rows.collect::<Result<_, _>>()?
502 };
503 ids.iter()
504 .map(|id| {
505 self.query_messages(
506 "WHERE folder = ?1 AND thread_id = ?2 ORDER BY internaldate, uid",
507 params![folder, id],
508 )
509 })
510 .collect()
511 }
512
513 pub fn thread_summaries(
516 &self,
517 folder: &str,
518 limit: u32,
519 filter: Filter,
520 ) -> Result<Vec<ThreadSummary>, StoreError> {
521 let mut stmt = self.conn.prepare(&format!(
522 "SELECT thread_id, {SUMMARY_COLUMNS}, MAX(internaldate) AS latest, COUNT(*), SUM({UNSEEN}), SUM({FLAGGED})
523 FROM messages WHERE folder = ?1 GROUP BY thread_id HAVING SUM({}) > 0
524 ORDER BY latest DESC, uid DESC LIMIT ?2",
525 filter.sql()
526 ))?;
527 let rows = stmt.query_map(params![folder, limit], |r| {
528 Ok(ThreadSummary {
529 thread_id: r.get(0)?,
530 latest: summary_from_row(r, 1)?,
531 count: r.get(8)?,
532 unread: r.get::<_, i64>(9)? > 0,
533 flagged: r.get::<_, i64>(10)? > 0,
534 })
535 })?;
536 Ok(rows.collect::<Result<_, _>>()?)
537 }
538
539 pub fn thread_members(
540 &self,
541 folder: &str,
542 thread_id: &str,
543 ) -> Result<Vec<MessageSummary>, StoreError> {
544 let mut stmt = self.conn.prepare(&format!(
545 "SELECT {SUMMARY_COLUMNS} FROM messages WHERE folder = ?1 AND thread_id = ?2 ORDER BY internaldate, uid"
546 ))?;
547 let rows = stmt.query_map(params![folder, thread_id], |r| summary_from_row(r, 0))?;
548 Ok(rows.collect::<Result<_, _>>()?)
549 }
550
551 pub fn messages_in_folder(&self, folder: &str) -> Result<Vec<Message>, StoreError> {
552 self.query_messages("WHERE folder = ?1 ORDER BY uid", params![folder])
553 }
554
555 pub fn messages_without_body(&self, folder: &str) -> Result<Vec<Message>, StoreError> {
557 self.query_messages(
558 "WHERE folder = ?1 AND body_text IS NULL ORDER BY uid",
559 params![folder],
560 )
561 }
562
563 pub fn lacks_bodies(&self, folder: Option<&str>) -> Result<bool, StoreError> {
565 Ok(self.conn.query_row(
566 "SELECT EXISTS (SELECT 1 FROM messages WHERE body_text IS NULL AND (?1 IS NULL OR folder = ?1))",
567 params![folder],
568 |r| r.get(0),
569 )?)
570 }
571
572 pub fn search(
574 &self,
575 query: &str,
576 folder: Option<&str>,
577 limit: u32,
578 ) -> Result<Vec<Message>, StoreError> {
579 if query.trim().is_empty() {
580 return Ok(Vec::new());
581 }
582 self.search_fts(query, folder, limit)
583 .or_else(|_| self.search_fts("e_words(query), folder, limit))
584 }
585
586 pub fn search_headers(
588 &self,
589 query: &str,
590 folder: Option<&str>,
591 limit: u32,
592 ) -> Result<Vec<Message>, StoreError> {
593 if query.trim().is_empty() {
594 return Ok(Vec::new());
595 }
596 let scoped = format!("{{subject from_addr to_addr}} : ({})", quote_words(query));
597 self.search_fts(&scoped, folder, limit)
598 }
599
600 fn search_fts(
601 &self,
602 query: &str,
603 folder: Option<&str>,
604 limit: u32,
605 ) -> Result<Vec<Message>, StoreError> {
606 self.query_messages(
607 "WHERE id IN (SELECT rowid FROM messages_fts WHERE messages_fts MATCH ?1)
608 AND (?2 IS NULL OR folder = ?2)
609 ORDER BY internaldate DESC, uid DESC LIMIT ?3",
610 params![query, folder, limit],
611 )
612 }
613
614 pub fn message(&self, folder: &str, uid: u32) -> Result<Option<Message>, StoreError> {
615 let mut stmt = self.conn.prepare_cached(&format!(
616 "SELECT {MESSAGE_COLUMNS} FROM messages WHERE folder = ?1 AND uid = ?2"
617 ))?;
618 let mut rows = stmt.query(params![folder, uid])?;
619 match rows.next()? {
620 Some(row) => Ok(Some(serde_rusqlite::from_row(row)?)),
621 None => Ok(None),
622 }
623 }
624
625 pub fn raw(&self, folder: &str, uid: u32) -> Result<Option<Vec<u8>>, StoreError> {
626 Ok(self
627 .conn
628 .query_row(
629 "SELECT raw FROM messages WHERE folder = ?1 AND uid = ?2",
630 params![folder, uid],
631 |r| r.get::<_, Option<Vec<u8>>>(0),
632 )
633 .optional()?
634 .flatten())
635 }
636
637 pub fn set_raw(
638 &self,
639 folder: &str,
640 uid: u32,
641 raw: &[u8],
642 body_text: &str,
643 ) -> Result<(), StoreError> {
644 self.conn.execute(
645 "UPDATE messages SET raw = ?3, body_text = ?4 WHERE folder = ?1 AND uid = ?2",
646 params![folder, uid, raw, body_text],
647 )?;
648 Ok(())
649 }
650
651 pub fn rule_first_seen(
653 &self,
654 name: &str,
655 definition: &str,
656 now: i64,
657 ) -> Result<i64, StoreError> {
658 self.conn.execute(
659 "INSERT INTO rules_seen (name, first_seen_at, definition) VALUES (?1, ?2, ?3)
660 ON CONFLICT (name) DO UPDATE SET
661 first_seen_at = CASE WHEN definition IS NULL OR definition = excluded.definition
662 THEN first_seen_at ELSE excluded.first_seen_at END,
663 definition = excluded.definition",
664 params![name, now, definition],
665 )?;
666 Ok(self.conn.query_row(
667 "SELECT first_seen_at FROM rules_seen WHERE name = ?1",
668 params![name],
669 |r| r.get(0),
670 )?)
671 }
672
673 pub fn restart_rule_clock(&self, name: &str, now: i64) -> Result<(), StoreError> {
675 self.conn.execute(
676 "INSERT OR REPLACE INTO rules_seen (name, first_seen_at) VALUES (?1, ?2)",
677 params![name, now],
678 )?;
679 Ok(())
680 }
681
682 pub fn mark_rules_seen_except(&self, except: &[String]) -> Result<(), StoreError> {
684 let except = serde_json::to_string(except)?;
685 self.conn.execute(
686 "UPDATE folders SET rules_uid = last_uid
687 WHERE last_uid > rules_uid AND name NOT IN (SELECT value FROM json_each(?1))",
688 params![except],
689 )?;
690 Ok(())
691 }
692
693 pub fn forget_rules_except(&self, names: &[&str]) -> Result<(), StoreError> {
695 let names = serde_json::to_string(names)?;
696 self.conn.execute(
697 "DELETE FROM rules_seen WHERE name NOT IN (SELECT value FROM json_each(?1))",
698 params![names],
699 )?;
700 Ok(())
701 }
702
703 pub fn log_action(&self, entry: &LogEntry) -> Result<(), StoreError> {
704 self.conn.execute(
705 "INSERT INTO rule_log (at, rule_name, folder, uid, message_id, subject, action, trash_file)
706 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
707 params![entry.at, entry.rule_name, entry.folder, entry.uid, entry.message_id, entry.subject, entry.action, entry.trash_file],
708 )?;
709 Ok(())
710 }
711
712 pub fn log(&self, limit: u32) -> Result<Vec<LogEntry>, StoreError> {
713 let mut stmt = self.conn.prepare(
714 "SELECT id, at, rule_name, folder, uid, message_id, subject, action, trash_file FROM rule_log ORDER BY id DESC LIMIT ?1",
715 )?;
716 let rows = serde_rusqlite::from_rows::<LogEntry>(stmt.query(params![limit])?);
717 Ok(rows.collect::<Result<Vec<_>, _>>()?)
718 }
719
720 pub fn message_count(&self, folder: &str) -> Result<u32, StoreError> {
721 Ok(self.conn.query_row(
722 "SELECT COUNT(*) FROM messages WHERE folder = ?1",
723 params![folder],
724 |r| r.get(0),
725 )?)
726 }
727
728 pub fn unread_count(&self, folder: &str) -> Result<u32, StoreError> {
729 Ok(self
730 .conn
731 .query_row(&unread_count_sql(), params![folder], |r| r.get(0))?)
732 }
733
734 pub fn unread_uids(&self, folder: &str) -> Result<Vec<u32>, StoreError> {
735 let mut stmt = self.conn.prepare(&format!(
736 "SELECT uid FROM messages WHERE folder = ?1 AND {UNSEEN} ORDER BY uid"
737 ))?;
738 let uids = stmt.query_map(params![folder], |r| r.get(0))?;
739 Ok(uids.collect::<Result<_, _>>()?)
740 }
741}
742
743fn quote_words(query: &str) -> String {
745 query
746 .split_whitespace()
747 .map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
748 .collect::<Vec<_>>()
749 .join(" ")
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755
756 #[test]
757 fn a_folder_keeps_its_delimiter_through_an_upsert_without_one() {
758 let store = Store::open_in_memory().unwrap();
759 let mut folder = Folder {
760 name: "Projects/Postvak".into(),
761 uidvalidity: 1,
762 last_uid: 0,
763 special_use: None,
764 delimiter: Some("/".into()),
765 };
766 store.upsert_folder(&folder).unwrap();
767 folder.delimiter = None;
768 folder.last_uid = 5;
769 store.upsert_folder(&folder).unwrap();
770 let stored = store.folder("Projects/Postvak").unwrap().unwrap();
771 assert_eq!(stored.delimiter.as_deref(), Some("/"));
772 assert_eq!(stored.last_uid, 5);
773 assert_eq!(store.folders().unwrap()[0].delimiter.as_deref(), Some("/"));
774 }
775
776 #[test]
777 fn open_account_makes_the_account_directory_private() {
778 use std::os::unix::fs::PermissionsExt;
779 let dir = tempfile::tempdir().unwrap();
780 let paths = Paths::under(dir.path());
781 Store::open_account(&paths, "work").unwrap();
782 let meta = std::fs::metadata(paths.account_dir("work")).unwrap();
783 assert_eq!(meta.permissions().mode() & 0o777, 0o700);
784 }
785
786 fn msg(folder: &str, uid: u32, internaldate: i64) -> Message {
787 Message {
788 folder: folder.into(),
789 uid,
790 message_id: Some(format!("m{uid}@x")),
791 from_addr: Some("Alice <alice@x>".into()),
792 to_addr: Some("bob@x".into()),
793 thread_id: format!("m{uid}@x"),
794 subject: Some(format!("subject {uid}")),
795 date: Some(internaldate),
796 internaldate,
797 size: Some(100),
798 headers: b"From: alice@x\r\n\r\n".to_vec(),
799 ..Default::default()
800 }
801 }
802
803 fn store_with_inbox() -> Store {
804 let s = Store::open_in_memory().unwrap();
805 s.upsert_folder(&Folder {
806 name: "INBOX".into(),
807 uidvalidity: 1,
808 last_uid: 0,
809 special_use: None,
810 delimiter: None,
811 })
812 .unwrap();
813 s
814 }
815
816 #[test]
817 fn migrations_are_valid() {
818 Store::migrations().validate().unwrap();
819 }
820
821 #[test]
822 fn every_migration_directory_is_listed() {
823 let dirs = std::fs::read_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations")).unwrap();
824 assert_eq!(dirs.count(), MIGRATIONS.len());
825 }
826
827 #[test]
828 fn open_creates_file_and_applies_schema() {
829 let dir = tempfile::tempdir().unwrap();
830 let path = dir.path().join("acc").join("mail.db");
831 let s = Store::open(&path).unwrap();
832 assert!(path.exists());
833 assert!(s.folders().unwrap().is_empty());
834 }
835
836 #[test]
837 fn folder_upsert_and_reset() {
838 let s = store_with_inbox();
839 s.insert_message(&msg("INBOX", 1, 10)).unwrap();
840 s.upsert_folder(&Folder {
841 name: "INBOX".into(),
842 uidvalidity: 1,
843 last_uid: 1,
844 special_use: None,
845 delimiter: None,
846 })
847 .unwrap();
848 assert_eq!(s.folder("INBOX").unwrap().unwrap().last_uid, 1);
849 s.reset_folder("INBOX", 2).unwrap();
850 let f = s.folder("INBOX").unwrap().unwrap();
851 assert_eq!((f.uidvalidity, f.last_uid), (2, 0));
852 assert!(s.messages_in_folder("INBOX").unwrap().is_empty());
853 }
854
855 #[test]
856 fn threads_group_by_thread_most_recent_activity_first() {
857 let s = store_with_inbox();
858 let mut root = msg("INBOX", 1, 10);
859 root.thread_id = "root@x".into();
860 let mut reply = msg("INBOX", 3, 30);
861 reply.thread_id = "root@x".into();
862 let other = msg("INBOX", 2, 20);
863 for m in [&root, &reply, &other] {
864 s.insert_message(m).unwrap();
865 }
866 let uids: Vec<Vec<u32>> = s
867 .threads("INBOX", 10, Filter::default())
868 .unwrap()
869 .iter()
870 .map(|thread| thread.iter().map(|m| m.uid).collect())
871 .collect();
872 assert_eq!(uids, [vec![1, 3], vec![2]]);
873 assert_eq!(s.threads("INBOX", 1, Filter::default()).unwrap().len(), 1);
874 }
875
876 #[test]
877 fn thread_depth_follows_reply_headers() {
878 let mut m = msg("INBOX", 1, 10);
879 assert_eq!(m.thread_depth(), 0);
880 m.in_reply_to = Some("a@x".into());
881 assert_eq!(m.thread_depth(), 1);
882 m.refs = Some("a@x b@x".into());
883 assert_eq!(m.thread_depth(), 2);
884 }
885
886 #[test]
887 fn messages_round_trip_and_order_newest_first() {
888 let s = store_with_inbox();
889 s.insert_message(&msg("INBOX", 1, 10)).unwrap();
890 s.insert_message(&msg("INBOX", 2, 20)).unwrap();
891 let list = s.messages("INBOX", 10, Filter::default()).unwrap();
892 assert_eq!(list.iter().map(|m| m.uid).collect::<Vec<_>>(), vec![2, 1]);
893 assert_eq!(list[1], msg("INBOX", 1, 10));
894 assert_eq!(s.message("INBOX", 3).unwrap(), None);
895 }
896
897 #[test]
898 fn flags_raw_and_removal() {
899 let s = store_with_inbox();
900 s.insert_message(&msg("INBOX", 1, 10)).unwrap();
901 s.update_flags("INBOX", 1, "\\Seen \\Flagged").unwrap();
902 assert!(s.message("INBOX", 1).unwrap().unwrap().is_seen());
903 assert_eq!(s.raw("INBOX", 1).unwrap(), None);
904 s.set_raw("INBOX", 1, b"raw bytes", "body words").unwrap();
905 assert_eq!(
906 s.raw("INBOX", 1).unwrap().as_deref(),
907 Some(&b"raw bytes"[..])
908 );
909 assert_eq!(
910 s.message("INBOX", 1).unwrap().unwrap().body_text.as_deref(),
911 Some("body words")
912 );
913 s.remove_message("INBOX", 1).unwrap();
914 assert_eq!(s.message("INBOX", 1).unwrap(), None);
915 }
916
917 fn fts_hits(s: &Store, query: &str) -> i64 {
918 s.conn
919 .query_row(
920 "SELECT count(*) FROM messages_fts WHERE messages_fts MATCH ?1",
921 params![query],
922 |r| r.get(0),
923 )
924 .unwrap()
925 }
926
927 fn fts_integrity_check(s: &Store) {
928 s.conn
929 .execute(
930 "INSERT INTO messages_fts(messages_fts) VALUES ('integrity-check')",
931 [],
932 )
933 .unwrap();
934 }
935
936 #[test]
937 fn fts_follows_rows_and_ignores_flag_updates() {
938 let s = store_with_inbox();
939 s.insert_message(&msg("INBOX", 1, 10)).unwrap();
940 assert_eq!(fts_hits(&s, "alice"), 1);
941 s.set_raw("INBOX", 1, b"raw", "pineapple").unwrap();
942 assert_eq!(fts_hits(&s, "pineapple"), 1);
943 let before = s.conn.total_changes();
944 s.update_flags("INBOX", 1, "").unwrap();
945 assert_eq!(
946 s.conn.total_changes(),
947 before,
948 "unchanged flags write nothing"
949 );
950 s.update_flags("INBOX", 1, "\\Seen").unwrap();
951 assert_eq!(
952 s.conn.total_changes(),
953 before + 1,
954 "a flag change does not touch the FTS index"
955 );
956 s.remove_message("INBOX", 1).unwrap();
957 assert_eq!(fts_hits(&s, "pineapple"), 0);
958 fts_integrity_check(&s);
959 }
960
961 #[test]
962 fn insert_ignores_duplicate_folder_uid() {
963 let s = store_with_inbox();
964 s.insert_message(&msg("INBOX", 1, 10)).unwrap();
965 let mut again = msg("INBOX", 1, 99);
966 again.subject = Some("other".into());
967 s.insert_message(&again).unwrap();
968 assert_eq!(
969 s.messages_in_folder("INBOX").unwrap(),
970 vec![msg("INBOX", 1, 10)]
971 );
972 }
973
974 #[test]
975 fn remove_missing_drops_uids_not_present() {
976 let s = store_with_inbox();
977 for uid in 1..=4 {
978 s.insert_message(&msg("INBOX", uid, uid as i64)).unwrap();
979 }
980 let removed = s.remove_missing("INBOX", 4, &[1, 3]).unwrap();
981 assert_eq!(removed, 2);
982 let left: Vec<u32> = s
983 .messages_in_folder("INBOX")
984 .unwrap()
985 .iter()
986 .map(|m| m.uid)
987 .collect();
988 assert_eq!(left, vec![1, 3]);
989 }
990
991 #[test]
992 fn rule_first_seen_is_sticky_while_the_definition_is_unchanged() {
993 let s = store_with_inbox();
994 assert_eq!(s.rule_first_seen("purge", "a", 100).unwrap(), 100);
995 assert_eq!(s.rule_first_seen("purge", "a", 200).unwrap(), 100);
996 assert_eq!(s.rule_first_seen("other", "a", 200).unwrap(), 200);
997 }
998
999 #[test]
1000 fn rule_first_seen_restarts_when_the_definition_changes() {
1001 let s = store_with_inbox();
1002 assert_eq!(s.rule_first_seen("purge", "a", 100).unwrap(), 100);
1003 assert_eq!(s.rule_first_seen("purge", "b", 200).unwrap(), 200);
1004 assert_eq!(s.rule_first_seen("purge", "b", 300).unwrap(), 200);
1005 }
1006
1007 #[test]
1008 fn restart_rule_clock_overwrites_the_first_seen_time() {
1009 let s = store_with_inbox();
1010 assert_eq!(s.rule_first_seen("purge", "a", 100).unwrap(), 100);
1011 s.restart_rule_clock("purge", 300).unwrap();
1012 assert_eq!(s.rule_first_seen("purge", "a", 400).unwrap(), 300);
1013 s.restart_rule_clock("fresh", 500).unwrap();
1014 assert_eq!(s.rule_first_seen("fresh", "a", 600).unwrap(), 500);
1015 }
1016
1017 #[test]
1018 fn migration_006_keeps_rule_clocks_and_adopts_the_next_definition() {
1019 let mut conn = Connection::open_in_memory().unwrap();
1020 Store::migrations().to_version(&mut conn, 5).unwrap();
1021 conn.execute(
1022 "INSERT INTO rules_seen (name, first_seen_at) VALUES ('purge', 100)",
1023 [],
1024 )
1025 .unwrap();
1026 let s = Store::init(conn).unwrap();
1027 assert_eq!(s.rule_first_seen("purge", "a", 200).unwrap(), 100);
1028 assert_eq!(s.rule_first_seen("purge", "b", 300).unwrap(), 300);
1029 }
1030
1031 #[test]
1032 fn log_round_trip_newest_first() {
1033 let s = store_with_inbox();
1034 for i in 1..=3 {
1035 s.log_action(&LogEntry {
1036 id: 0,
1037 at: i,
1038 rule_name: "r".into(),
1039 folder: "INBOX".into(),
1040 uid: i as u32,
1041 message_id: None,
1042 subject: None,
1043 action: "delete".into(),
1044 trash_file: Some(format!("{i}.eml")),
1045 })
1046 .unwrap();
1047 }
1048 let log = s.log(2).unwrap();
1049 assert_eq!(log.iter().map(|e| e.at).collect::<Vec<_>>(), vec![3, 2]);
1050 }
1051
1052 #[test]
1053 fn unread_and_message_counts() {
1054 let s = store_with_inbox();
1055 s.insert_message(&msg("INBOX", 1, 10)).unwrap();
1056 s.insert_message(&msg("INBOX", 2, 20)).unwrap();
1057 s.update_flags("INBOX", 2, "\\Seen").unwrap();
1058 assert_eq!(s.unread_count("INBOX").unwrap(), 1);
1059 assert_eq!(s.unread_uids("INBOX").unwrap(), [1]);
1060 assert_eq!(s.message_count("INBOX").unwrap(), 2);
1061 assert_eq!(s.message_count("Other").unwrap(), 0);
1062 }
1063
1064 #[test]
1065 fn search_matches_words_newest_first_and_filters_by_folder() {
1066 let s = store_with_inbox();
1067 s.upsert_folder(&Folder {
1068 name: "Archive".into(),
1069 uidvalidity: 1,
1070 last_uid: 0,
1071 special_use: None,
1072 delimiter: None,
1073 })
1074 .unwrap();
1075 s.insert_message(&msg("INBOX", 1, 10)).unwrap();
1076 s.insert_message(&msg("INBOX", 2, 30)).unwrap();
1077 s.insert_message(&msg("Archive", 3, 20)).unwrap();
1078 let uids = |found: Vec<Message>| found.iter().map(|m| m.uid).collect::<Vec<_>>();
1079 assert_eq!(uids(s.search("subject", None, 10).unwrap()), [2, 3, 1]);
1080 assert_eq!(
1081 uids(s.search("subject", Some("INBOX"), 10).unwrap()),
1082 [2, 1]
1083 );
1084 assert_eq!(uids(s.search("subject", None, 1).unwrap()), [2]);
1085 assert_eq!(
1086 uids(s.search("from_addr:alice", None, 10).unwrap()),
1087 [2, 3, 1]
1088 );
1089 assert!(s.search("pineapple", None, 10).unwrap().is_empty());
1090 assert!(s.search(" ", None, 10).unwrap().is_empty());
1091 }
1092
1093 #[test]
1094 fn search_retries_unparsable_queries_as_quoted_words() {
1095 let s = store_with_inbox();
1096 s.insert_message(&msg("INBOX", 1, 10)).unwrap();
1097 assert_eq!(s.search("alice@x", None, 10).unwrap().len(), 1);
1098 assert!(s.search("re: lunch", None, 10).unwrap().is_empty());
1099 assert!(s.search("\"unbalanced", None, 10).unwrap().is_empty());
1100 }
1101
1102 #[test]
1103 fn header_search_ignores_bodies_even_through_fts_syntax() {
1104 let store = store_with_inbox();
1105 let mut m = msg("INBOX", 1, 100);
1106 m.subject = Some("hello".into());
1107 m.body_text = Some("secret word".into());
1108 store.insert_message(&m).unwrap();
1109 assert_eq!(store.search_headers("hello", None, 10).unwrap().len(), 1);
1110 assert!(store.search_headers("secret", None, 10).unwrap().is_empty());
1111 assert!(
1112 store
1113 .search_headers("x) OR (body_text:secret", None, 10)
1114 .unwrap()
1115 .is_empty()
1116 );
1117 assert!(
1118 store
1119 .search_headers("x) OR body_text:secret", None, 10)
1120 .unwrap()
1121 .is_empty()
1122 );
1123 assert_eq!(store.search("secret", None, 10).unwrap().len(), 1);
1124 }
1125
1126 #[test]
1127 fn search_covers_fetched_bodies() {
1128 let s = store_with_inbox();
1129 s.insert_message(&msg("INBOX", 1, 10)).unwrap();
1130 assert!(s.search("pineapple", None, 10).unwrap().is_empty());
1131 s.set_raw("INBOX", 1, b"raw", "pineapple pie").unwrap();
1132 assert_eq!(s.search("pineapple", None, 10).unwrap().len(), 1);
1133 }
1134
1135 #[test]
1136 fn migration_002_keeps_rows_and_marks_existing_mail_as_processed() {
1137 let mut conn = Connection::open_in_memory().unwrap();
1138 Store::migrations().to_version(&mut conn, 1).unwrap();
1139 conn.execute(
1140 "INSERT INTO folders (name, uidvalidity, last_uid) VALUES ('INBOX', 7, 42)",
1141 [],
1142 )
1143 .unwrap();
1144 let s = Store::init(conn).unwrap();
1145 let inbox = s.folder("INBOX").unwrap().unwrap();
1146 assert_eq!((inbox.uidvalidity, inbox.last_uid), (7, 42));
1147 assert_eq!(s.initial_uid_next("INBOX").unwrap(), 0);
1148 assert_eq!(s.rules_uid("INBOX").unwrap(), 42);
1149 }
1150
1151 #[test]
1152 fn migration_003_counts_mail_the_rules_saw_as_notified() {
1153 let mut conn = Connection::open_in_memory().unwrap();
1154 Store::migrations().to_version(&mut conn, 2).unwrap();
1155 conn.execute(
1156 "INSERT INTO folders (name, uidvalidity, last_uid, rules_uid) VALUES ('INBOX', 7, 42, 40)",
1157 [],
1158 )
1159 .unwrap();
1160 let s = Store::init(conn).unwrap();
1161 assert_eq!(s.rules_uid("INBOX").unwrap(), 40);
1162 assert_eq!(s.notified_uid("INBOX").unwrap(), 40);
1163 s.set_notified_uid("INBOX", 42).unwrap();
1164 assert_eq!(s.notified_uid("INBOX").unwrap(), 42);
1165 }
1166
1167 #[test]
1168 fn migration_004_drops_the_message_id_index() {
1169 let s = store_with_inbox();
1170 let indexes: i64 = s
1171 .conn
1172 .query_row(
1173 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'messages_message_id'",
1174 [],
1175 |row| row.get(0),
1176 )
1177 .unwrap();
1178 assert_eq!(indexes, 0);
1179 }
1180
1181 #[test]
1182 fn the_thread_list_reads_only_the_covering_index_and_the_unread_count_only_unread_mail() {
1183 let s = store_with_inbox();
1184 for uid in 1..=500 {
1186 s.insert_message(&msg("INBOX", uid, i64::from(uid)))
1187 .unwrap();
1188 }
1189 let plan = |sql: &str| -> String {
1190 let mut stmt = s
1191 .conn
1192 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))
1193 .unwrap();
1194 let rows = stmt.query_map([], |r| r.get::<_, String>(3)).unwrap();
1195 rows.collect::<Result<Vec<_>, _>>().unwrap().join("\n")
1196 };
1197 let thread_list = plan(&format!(
1198 "SELECT thread_id, {SUMMARY_COLUMNS}, MAX(internaldate) FROM messages WHERE folder = 'INBOX' GROUP BY thread_id"
1199 ));
1200 assert!(
1201 thread_list.contains("COVERING INDEX messages_folder_thread"),
1202 "{thread_list}"
1203 );
1204 let unread = plan(&unread_count_sql().replace("?1", "'INBOX'"));
1205 assert!(unread.contains("INDEX messages_unread"), "{unread}");
1206 }
1207
1208 #[test]
1209 fn the_unread_count_follows_the_seen_flag() {
1210 let s = store_with_inbox();
1211 for uid in 1..=4 {
1212 s.insert_message(&msg("INBOX", uid, i64::from(uid)))
1213 .unwrap();
1214 }
1215 let unread = s.unread_count("INBOX").unwrap();
1216 s.update_flags("INBOX", 2, r"\Seen \Flagged").unwrap();
1217 assert_eq!(s.unread_count("INBOX").unwrap(), unread - 1);
1218 s.update_flags("INBOX", 2, r"\Flagged").unwrap();
1219 assert_eq!(s.unread_count("INBOX").unwrap(), unread);
1220 assert_eq!(s.unread_count("Archive").unwrap(), 0);
1221 }
1222
1223 #[test]
1224 fn rules_uid_round_trips_and_survives_upsert() {
1225 let s = store_with_inbox();
1226 assert_eq!(s.rules_uid("INBOX").unwrap(), 0);
1227 assert_eq!(s.rules_uid("Nope").unwrap(), 0);
1228 s.set_rules_uid("INBOX", 77).unwrap();
1229 s.upsert_folder(&Folder {
1230 name: "INBOX".into(),
1231 uidvalidity: 1,
1232 last_uid: 500,
1233 special_use: None,
1234 delimiter: None,
1235 })
1236 .unwrap();
1237 assert_eq!(s.rules_uid("INBOX").unwrap(), 77);
1238 }
1239
1240 #[test]
1241 fn mark_rules_seen_except_skips_listed_folders_and_never_lowers() {
1242 let s = store_with_inbox();
1243 for (name, last_uid) in [("INBOX", 50), ("Lists", 30), ("Old", 10)] {
1244 s.upsert_folder(&Folder {
1245 name: name.into(),
1246 uidvalidity: 1,
1247 last_uid,
1248 special_use: None,
1249 delimiter: None,
1250 })
1251 .unwrap();
1252 }
1253 s.set_rules_uid("Old", 20).unwrap();
1254 s.mark_rules_seen_except(&["INBOX".to_string()]).unwrap();
1255 assert_eq!(s.rules_uid("INBOX").unwrap(), 0);
1256 assert_eq!(s.rules_uid("Lists").unwrap(), 30);
1257 assert_eq!(s.rules_uid("Old").unwrap(), 20);
1258 }
1259
1260 #[test]
1261 fn initial_uid_next_round_trips_and_survives_upsert() {
1262 let s = store_with_inbox();
1263 assert_eq!(s.initial_uid_next("INBOX").unwrap(), 0);
1264 assert_eq!(s.initial_uid_next("Nope").unwrap(), 0);
1265 s.set_initial_uid_next("INBOX", 1201).unwrap();
1266 s.upsert_folder(&Folder {
1267 name: "INBOX".into(),
1268 uidvalidity: 1,
1269 last_uid: 500,
1270 special_use: None,
1271 delimiter: None,
1272 })
1273 .unwrap();
1274 assert_eq!(s.initial_uid_next("INBOX").unwrap(), 1201);
1275 }
1276
1277 fn filter_store() -> Store {
1279 let s = store_with_inbox();
1280 for (uid, flags) in [
1281 (1, "\\Seen"),
1282 (2, ""),
1283 (3, "\\Seen \\Flagged"),
1284 (4, "\\Seen"),
1285 ] {
1286 let mut m = msg("INBOX", uid, i64::from(uid));
1287 m.thread_id = format!("t{uid}");
1288 m.flags = flags.into();
1289 if uid == 4 {
1290 m.headers =
1291 b"Subject: x\r\nContent-Type:\r\n Multipart/Mixed; boundary=b\r\n\r\n".to_vec();
1292 }
1293 s.insert_message(&m).unwrap();
1294 }
1295 s
1296 }
1297
1298 #[test]
1299 fn messages_keep_only_what_the_filter_asks_for() {
1300 let s = filter_store();
1301 let uids = |filter: Filter| -> Vec<u32> {
1302 s.messages("INBOX", 10, filter)
1303 .unwrap()
1304 .iter()
1305 .map(|m| m.uid)
1306 .collect()
1307 };
1308 assert_eq!(uids(Filter::default()), [4, 3, 2, 1]);
1309 assert_eq!(
1310 uids(Filter {
1311 unread: true,
1312 ..Filter::default()
1313 }),
1314 [2]
1315 );
1316 assert_eq!(
1317 uids(Filter {
1318 flagged: true,
1319 ..Filter::default()
1320 }),
1321 [3]
1322 );
1323 assert_eq!(
1324 uids(Filter {
1325 attachments: true,
1326 ..Filter::default()
1327 }),
1328 [4]
1329 );
1330 assert!(
1331 uids(Filter {
1332 unread: true,
1333 flagged: true,
1334 ..Filter::default()
1335 })
1336 .is_empty()
1337 );
1338 }
1339
1340 #[test]
1341 fn a_thread_matches_the_filter_when_any_member_does() {
1342 let s = filter_store();
1343 let mut reply = msg("INBOX", 5, 5);
1344 reply.thread_id = "t1".into();
1345 s.insert_message(&reply).unwrap();
1346 let unread = Filter {
1347 unread: true,
1348 ..Filter::default()
1349 };
1350 let summaries: Vec<String> = s
1351 .thread_summaries("INBOX", 10, unread)
1352 .unwrap()
1353 .into_iter()
1354 .map(|t| t.thread_id)
1355 .collect();
1356 assert_eq!(summaries, ["t1", "t2"]);
1357 let threads: Vec<usize> = s
1358 .threads("INBOX", 10, unread)
1359 .unwrap()
1360 .iter()
1361 .map(Vec::len)
1362 .collect();
1363 assert_eq!(threads, [2, 1], "a matching thread keeps all its members");
1364 }
1365
1366 #[test]
1367 fn thread_summaries_describe_the_latest_message() {
1368 let s = store_with_inbox();
1369 let mut first = msg("INBOX", 1, 10);
1370 first.thread_id = "t1".into();
1371 first.flags = "\\Seen".into();
1372 let mut reply = msg("INBOX", 2, 30);
1373 reply.thread_id = "t1".into();
1374 reply.flags = "\\Flagged".into();
1375 let mut other = msg("INBOX", 3, 20);
1376 other.thread_id = "t2".into();
1377 other.flags = "\\Seen".into();
1378 for m in [&first, &reply, &other] {
1379 s.insert_message(m).unwrap();
1380 }
1381 let threads = s.thread_summaries("INBOX", 10, Filter::default()).unwrap();
1382 assert_eq!(
1383 threads,
1384 vec![
1385 ThreadSummary {
1386 thread_id: "t1".into(),
1387 latest: MessageSummary {
1388 uid: 2,
1389 from: "Alice <alice@x>".into(),
1390 to: "bob@x".into(),
1391 subject: "subject 2".into(),
1392 date: 30,
1393 flags: "\\Flagged".into(),
1394 },
1395 count: 2,
1396 unread: true,
1397 flagged: true,
1398 },
1399 ThreadSummary {
1400 thread_id: "t2".into(),
1401 latest: MessageSummary {
1402 uid: 3,
1403 from: "Alice <alice@x>".into(),
1404 to: "bob@x".into(),
1405 subject: "subject 3".into(),
1406 date: 20,
1407 flags: "\\Seen".into(),
1408 },
1409 count: 1,
1410 unread: false,
1411 flagged: false,
1412 },
1413 ]
1414 );
1415 assert_eq!(
1416 s.thread_summaries("INBOX", 1, Filter::default())
1417 .unwrap()
1418 .len(),
1419 1
1420 );
1421 let members: Vec<u32> = s
1422 .thread_members("INBOX", "t1")
1423 .unwrap()
1424 .iter()
1425 .map(|m| m.uid)
1426 .collect();
1427 assert_eq!(members, [1, 2]);
1428 }
1429
1430 #[test]
1431 fn summaries_tolerate_missing_headers() {
1432 let s = store_with_inbox();
1433 let mut bare = msg("INBOX", 1, 10);
1434 bare.from_addr = None;
1435 bare.to_addr = None;
1436 bare.subject = None;
1437 s.insert_message(&bare).unwrap();
1438 let latest = &s.thread_summaries("INBOX", 10, Filter::default()).unwrap()[0].latest;
1439 assert_eq!((latest.from.as_str(), latest.subject.as_str()), ("", ""));
1440 assert!(!latest.is_seen() && !latest.is_flagged());
1441 }
1442}