Skip to main content

postvak_core/
store.rs

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
10/// In order; a new directory under `migrations/` needs its line here too.
11const 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
72/// Whether the space-separated `flags` contain `flag`.
73pub fn has_flag(flags: &str, flag: &str) -> bool {
74    flags.split_whitespace().any(|f| f == flag)
75}
76
77/// Keywords compare case-insensitively (RFC 3501).
78pub 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    /// How deep in its thread the message sits, judged from its own reply headers.
94    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/// What a list row shows; no headers or body.
104#[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/// A collapsed thread row: its latest message plus counts over all members in the folder.
125#[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
145/// SQL conditions on a message row's space-separated `flags`.
146const UNSEEN: &str = r"instr(' ' || flags || ' ', ' \Seen ') = 0";
147const FLAGGED: &str = r"instr(' ' || flags || ' ', ' \Flagged ') > 0";
148
149/// A folder's unread count, from the index of unread messages alone (migration 010). Without statistics SQLite would
150/// pick the covering folder index and read the whole folder; a condition other than the index's makes this an error.
151fn unread_count_sql() -> String {
152    format!(
153        "SELECT COUNT(*) FROM messages INDEXED BY messages_unread WHERE folder = ?1 AND {UNSEEN}"
154    )
155}
156
157/// Which messages a list keeps; each set field must hold, and the default keeps all.
158#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
159pub struct Filter {
160    pub unread: bool,
161    pub flagged: bool,
162    /// Guessed from the headers: a top-level multipart/mixed.
163    pub attachments: bool,
164}
165
166impl Filter {
167    /// The filter as one SQL condition on a message row; `1` when it keeps all.
168    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
203/// serde's `Vec<u8>` only accepts sequences; rusqlite offers BLOBs as bytes.
204fn 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    /// The account's store, in its private directory.
241    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        // Clients migrate and write rule clocks while the daemon may hold the write lock.
255        conn.busy_timeout(std::time::Duration::from_secs(5))?;
256        Store::migrations().to_latest(&mut conn)?;
257        Ok(Store { conn })
258    }
259
260    /// Runs `f` in one transaction; any error rolls back everything `f` wrote.
261    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    /// One above the highest uid already on the server when `folder` was first tracked; 0 when unknown.
304    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    /// The highest uid the rules have evaluated in `folder`; above it mail is fresh. 0 when unknown.
313    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    /// The highest uid new-mail notifications have covered in `folder`; 0 when unknown.
322    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    /// The HIGHESTMODSEQ (0 when unknown) and Unix time at which `folder`'s flags were last fully up to date.
331    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        // RFC 7162 caps mod-sequences at 2^63 - 1, so they fit SQLite's signed INTEGER.
350        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    /// A uid column of the `folders` row; 0 when the folder is untracked.
358    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    /// Removes the folder with its messages and its rule and notification state.
394    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    /// Removes rows with uid <= `upto_uid` whose uid is not in `present`.
444    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    /// `SELECT` every message column, then `tail`; the statement is cached, so a repeated `tail` is prepared once.
474    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    /// Threads in `folder` with a member `filter` keeps, the most recently active first; each thread's messages oldest
487    /// first.
488    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    /// One row per thread in `folder` with a member `filter` keeps, the most recently active first. With exactly one MAX() in the query, SQLite fills
514    /// the bare columns from the row holding it: the thread's latest message. A second MAX() anywhere breaks that.
515    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    /// The messages in `folder` whose body is not downloaded yet, by uid.
556    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    /// Whether any message in `folder`, or in any folder, lacks its body.
564    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    /// FTS5 search, newest first. A query FTS5 cannot parse, such as a bare email address, is retried with every word quoted.
573    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(&quote_words(query), folder, limit))
584    }
585
586    /// Search over subject and addresses only; every word is quoted, because FTS5 syntax could escape the column filter.
587    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    /// Returns when the rule was first seen with this definition, starting its clock at `now` if it is new or changed.
652    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    /// Starts the rule's clock at `now`, replacing any stale first-seen time; the next load adopts its definition.
674    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    /// Moves `rules_uid` up to `last_uid` in every folder not in `except`, so their mail stops counting as fresh.
683    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    /// Drops first-seen times of rules no longer in the file, so a re-added rule starts fresh instead of acting on history.
694    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
743/// Every whitespace-separated word as an FTS5 string, which FTS5 always parses.
744fn 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        // Without statistics an empty table plans differently; a few hundred rows plan like a real folder.
1185        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    /// uid 1 read, 2 unread, 3 flagged and read, 4 read with a multipart/mixed Content-Type folded onto a second line.
1278    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}