Skip to main content

io_maildir/flag/
types.rs

1//! Maildir flag set: IANA letter flags + custom keywords.
2
3use core::fmt;
4use core::fmt::Write as _;
5use core::str::FromStr;
6
7use alloc::{
8    collections::{BTreeMap, BTreeSet},
9    string::String,
10    vec::Vec,
11};
12
13use log::trace;
14
15use crate::path::FsPath;
16
17/// A set of Maildir flags plus opaque info-section letters.
18#[derive(Clone, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
19pub struct MaildirFlags {
20    flags: BTreeSet<MaildirFlag>,
21    /// Resolved dovecot `a..z` slot letters with no named-variant
22    /// counterpart, appended verbatim by [`fmt::Display`].
23    extra_letters: BTreeSet<char>,
24}
25
26impl From<&FsPath> for MaildirFlags {
27    fn from(path: &FsPath) -> Self {
28        let Some(file_name) = path.file_name() else {
29            return Default::default();
30        };
31
32        let Some((_, flags)) = file_name.rsplit_once(',') else {
33            return Default::default();
34        };
35
36        MaildirFlags::from_iter(flags.chars().filter_map(MaildirFlag::from_char))
37    }
38}
39
40impl fmt::Display for MaildirFlags {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        // NOTE: BTreeSet iterates in sorted order so the on-disk
43        // representation is deterministic.
44        for flag in &self.flags {
45            write!(f, "{flag}")?;
46        }
47        for letter in &self.extra_letters {
48            f.write_char(*letter)?;
49        }
50        Ok(())
51    }
52}
53
54impl MaildirFlags {
55    pub fn is_empty(&self) -> bool {
56        self.flags.is_empty() && self.extra_letters.is_empty()
57    }
58
59    pub fn len(&self) -> usize {
60        self.flags.len() + self.extra_letters.len()
61    }
62
63    pub fn contains(&self, flag: &MaildirFlag) -> bool {
64        self.flags.contains(flag)
65    }
66
67    pub fn extend(&mut self, flags: MaildirFlags) {
68        self.flags.extend(flags.flags);
69        self.extra_letters.extend(flags.extra_letters);
70    }
71
72    pub fn difference(&mut self, flags: &MaildirFlags) {
73        self.flags = self.flags.difference(&flags.flags).cloned().collect();
74        self.extra_letters = self
75            .extra_letters
76            .difference(&flags.extra_letters)
77            .copied()
78            .collect();
79    }
80
81    pub fn iter(&self) -> impl Iterator<Item = &MaildirFlag> {
82        self.flags.iter()
83    }
84
85    pub fn insert(&mut self, flag: MaildirFlag) -> bool {
86        self.flags.insert(flag)
87    }
88
89    /// Like [`From<&MaildirPath>`] but resolves lowercase `a..z`
90    /// letters through a dovecot-keywords table.
91    pub fn with_dovecot(path: &FsPath, table: &BTreeMap<char, String>) -> Self {
92        let Some(file_name) = path.file_name() else {
93            return Default::default();
94        };
95
96        let Some((_, letters)) = file_name.rsplit_once(',') else {
97            return Default::default();
98        };
99
100        let mut flags = BTreeSet::new();
101        for c in letters.chars() {
102            if let Some(flag) = MaildirFlag::from_char(c) {
103                flags.insert(flag);
104            } else if c.is_ascii_lowercase() {
105                if let Some(name) = table.get(&c) {
106                    flags.insert(MaildirFlag::Keyword(name.clone()));
107                }
108            }
109        }
110        MaildirFlags {
111            flags,
112            extra_letters: BTreeSet::new(),
113        }
114    }
115
116    /// Adds raw keyword strings as [`MaildirFlag::Keyword`] entries.
117    pub fn extend_keywords<I, S>(&mut self, keywords: I)
118    where
119        I: IntoIterator<Item = S>,
120        S: Into<String>,
121    {
122        for k in keywords {
123            self.flags.insert(MaildirFlag::Keyword(k.into()));
124        }
125    }
126
127    /// Appends raw info-section letters written verbatim by
128    /// [`fmt::Display`] (typically resolved dovecot slot letters).
129    pub fn extend_letters<I>(&mut self, letters: I)
130    where
131        I: IntoIterator<Item = char>,
132    {
133        self.extra_letters.extend(letters);
134    }
135
136    /// Drains every [`MaildirFlag::Keyword`] variant out, returning
137    /// the keyword strings in lexicographic order.
138    pub fn drain_keywords(&mut self) -> Vec<String> {
139        let keywords: BTreeSet<MaildirFlag> = self
140            .flags
141            .iter()
142            .filter(|f| matches!(f, MaildirFlag::Keyword(_)))
143            .cloned()
144            .collect();
145
146        for f in &keywords {
147            self.flags.remove(f);
148        }
149
150        keywords
151            .into_iter()
152            .filter_map(|f| match f {
153                MaildirFlag::Keyword(s) => Some(s),
154                _ => None,
155            })
156            .collect()
157    }
158}
159
160impl FromIterator<MaildirFlag> for MaildirFlags {
161    fn from_iter<I: IntoIterator<Item = MaildirFlag>>(iter: I) -> Self {
162        MaildirFlags {
163            flags: iter.into_iter().collect(),
164            extra_letters: BTreeSet::new(),
165        }
166    }
167}
168
169/// A single Maildir flag: a standard IANA letter or a custom keyword.
170#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
171pub enum MaildirFlag {
172    Passed,
173    Replied,
174    Seen,
175    Trashed,
176    Draft,
177    Flagged,
178    /// Custom keyword with no info-section letter; serialised via
179    /// dovecot-keywords or a configured header.
180    Keyword(String),
181}
182
183impl MaildirFlag {
184    pub fn from_char(c: char) -> Option<MaildirFlag> {
185        match c {
186            'P' => Some(MaildirFlag::Passed),
187            'R' => Some(MaildirFlag::Replied),
188            'S' => Some(MaildirFlag::Seen),
189            'T' => Some(MaildirFlag::Trashed),
190            'D' => Some(MaildirFlag::Draft),
191            'F' => Some(MaildirFlag::Flagged),
192            c => {
193                trace!("invalid maildir flag `{c}`, ignoring");
194                None
195            }
196        }
197    }
198
199    pub fn keyword(s: impl Into<String>) -> Self {
200        Self::Keyword(s.into())
201    }
202
203    pub fn as_keyword(&self) -> Option<&str> {
204        match self {
205            Self::Keyword(s) => Some(s.as_str()),
206            _ => None,
207        }
208    }
209}
210
211impl fmt::Display for MaildirFlag {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        match self {
214            Self::Passed => write!(f, "P"),
215            Self::Replied => write!(f, "R"),
216            Self::Seen => write!(f, "S"),
217            Self::Trashed => write!(f, "T"),
218            Self::Draft => write!(f, "D"),
219            Self::Flagged => write!(f, "F"),
220            // NOTE: Keyword has no letter encoding; serialised via the
221            // dovecot-keywords file or a header instead.
222            Self::Keyword(_) => Ok(()),
223        }
224    }
225}
226
227/// Header used to carry custom keywords inline with the message body.
228///
229/// `XKeywords` follows the OfflineIMAP / mbsync convention (comma-
230/// separated); `XLabel` follows mutt / notmuch (space-separated).
231#[derive(Clone, Copy, Debug, Eq, PartialEq)]
232pub enum KeywordHeader {
233    XKeywords,
234    XLabel,
235}
236
237impl KeywordHeader {
238    pub fn header_name(&self) -> &'static str {
239        match self {
240            Self::XKeywords => "X-Keywords",
241            Self::XLabel => "X-Label",
242        }
243    }
244
245    pub fn separator(&self) -> char {
246        match self {
247            Self::XKeywords => ',',
248            Self::XLabel => ' ',
249        }
250    }
251}
252
253impl FromStr for KeywordHeader {
254    type Err = &'static str;
255
256    fn from_str(s: &str) -> Result<Self, Self::Err> {
257        match s.to_ascii_lowercase().as_str() {
258            "x-keywords" | "xkeywords" | "x_keywords" => Ok(Self::XKeywords),
259            "x-label" | "xlabel" | "x_label" => Ok(Self::XLabel),
260            _ => Err("expected `x-keywords` or `x-label`"),
261        }
262    }
263}
264
265impl fmt::Display for KeywordHeader {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        f.write_str(self.header_name())
268    }
269}