Skip to main content

io_email/flag/
types.rs

1//! Email flag (a.k.a. keyword) shared across all protocols.
2//!
3//! A [`Flag`] carries two views of the same value: the original wire
4//! spelling as observed on the backend, and an optional [`IanaFlag`]
5//! classification when the wire string matches an IANA-registered
6//! keyword (case-insensitive, leading `\` or `$` stripped). Custom
7//! user-defined keywords flow through unchanged with `iana = None`.
8//!
9//! Equality and ordering are IANA-first so that wire spellings like
10//! `\Seen`, `$seen` and `seen` collapse to the same logical flag while
11//! custom keywords compare case-insensitively. This lets a
12//! `BTreeSet<Flag>` (the storage shape used by [`Envelope::flags`]) act
13//! as a normalised set across backends.
14//!
15//! [`IanaFlag::Deleted`] is a sync verb rather than a propagatable
16//! flag: when a sync engine sees one side carrying it, it should
17//! dispatch `delete_message` on the other side, never copy the flag
18//! across. Adapters still read and write `\Deleted` so a single-side
19//! workflow (purge, expunge) behaves correctly.
20//!
21//! [`Envelope::flags`]: crate::envelope::types::Envelope::flags
22
23use core::{cmp::Ordering, hash::Hash};
24
25use alloc::string::{String, ToString};
26
27/// A flag attached to an envelope or message.
28///
29/// Constructed via [`Flag::from_raw`] (wire spelling in, IANA lookup
30/// derived) or [`Flag::from_iana`] (IANA tag in, canonical wire
31/// spelling synthesised). The constructor is named `from_iana` rather
32/// than `iana` to leave [`Flag::iana`] free as the accessor that
33/// returns the optional classification.
34#[derive(Clone, Debug)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36pub struct Flag {
37    raw: String,
38    iana: Option<IanaFlag>,
39}
40
41/// IANA-registered email keywords supported by the shared API.
42///
43/// Listed in the canonical wire-spelling table at
44/// <https://www.iana.org/assignments/imap-jmap-keywords/>. Variant
45/// order is the lookup order; [`Ord`] is derived from declaration
46/// order to give stable per-IANA-key sorting.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
50pub enum IanaFlag {
51    Seen,
52    Answered,
53    Flagged,
54    Draft,
55    /// Sync verb. Adapters round-trip `\Deleted` so single-side
56    /// workflows behave, but a sync engine should translate
57    /// "one side has Deleted" into `delete_message` on the other
58    /// side rather than propagating the flag.
59    Deleted,
60    Forwarded,
61    Junk,
62    NotJunk,
63    Phishing,
64    Important,
65    MdnSent,
66}
67
68impl Flag {
69    /// Builds a [`Flag`] from a wire spelling. The raw string is kept
70    /// verbatim; the IANA classification is derived by
71    /// [`classify_iana`].
72    pub fn from_raw(raw: impl Into<String>) -> Self {
73        let raw = raw.into();
74        let iana = classify_iana(&raw);
75        Self { raw, iana }
76    }
77
78    /// Builds a [`Flag`] tagged with an [`IanaFlag`] and the matching
79    /// canonical wire spelling (`\Seen`, `$Forwarded`, …). Used by
80    /// adapters whose wire format does not carry casing (Maildir
81    /// letters) or when synthesising flags client-side.
82    pub fn from_iana(iana: IanaFlag) -> Self {
83        Self {
84            raw: canonical_raw(iana).to_string(),
85            iana: Some(iana),
86        }
87    }
88
89    /// Original wire spelling as observed on the backend (or the
90    /// canonical spelling when built from an [`IanaFlag`]).
91    pub fn raw(&self) -> &str {
92        &self.raw
93    }
94
95    /// IANA classification when the raw spelling matched a registered
96    /// keyword; `None` for user-defined custom keywords.
97    pub fn iana(&self) -> Option<IanaFlag> {
98        self.iana
99    }
100
101    pub fn is_seen(&self) -> bool {
102        matches!(self.iana, Some(IanaFlag::Seen))
103    }
104
105    pub fn is_answered(&self) -> bool {
106        matches!(self.iana, Some(IanaFlag::Answered))
107    }
108
109    pub fn is_flagged(&self) -> bool {
110        matches!(self.iana, Some(IanaFlag::Flagged))
111    }
112
113    pub fn is_draft(&self) -> bool {
114        matches!(self.iana, Some(IanaFlag::Draft))
115    }
116
117    pub fn is_deleted(&self) -> bool {
118        matches!(self.iana, Some(IanaFlag::Deleted))
119    }
120
121    pub fn is_forwarded(&self) -> bool {
122        matches!(self.iana, Some(IanaFlag::Forwarded))
123    }
124
125    pub fn is_junk(&self) -> bool {
126        matches!(self.iana, Some(IanaFlag::Junk))
127    }
128
129    pub fn is_notjunk(&self) -> bool {
130        matches!(self.iana, Some(IanaFlag::NotJunk))
131    }
132
133    pub fn is_phishing(&self) -> bool {
134        matches!(self.iana, Some(IanaFlag::Phishing))
135    }
136
137    pub fn is_important(&self) -> bool {
138        matches!(self.iana, Some(IanaFlag::Important))
139    }
140
141    pub fn is_mdnsent(&self) -> bool {
142        matches!(self.iana, Some(IanaFlag::MdnSent))
143    }
144}
145
146impl PartialEq for Flag {
147    fn eq(&self, other: &Self) -> bool {
148        match (self.iana, other.iana) {
149            (Some(a), Some(b)) => a == b,
150            (None, None) => self.raw.eq_ignore_ascii_case(&other.raw),
151            _ => false,
152        }
153    }
154}
155
156impl Eq for Flag {}
157
158impl Ord for Flag {
159    /// IANA-tagged flags sort before custom keywords (IANA-first
160    /// canonical ordering). Within each group, IANA flags use the
161    /// derived enum order and custom keywords compare on their
162    /// lowercase raw text so that `"Foo"` and `"foo"` order
163    /// consistently with equality.
164    fn cmp(&self, other: &Self) -> Ordering {
165        match (self.iana, other.iana) {
166            (Some(a), Some(b)) => a.cmp(&b),
167            (Some(_), None) => Ordering::Less,
168            (None, Some(_)) => Ordering::Greater,
169            (None, None) => self
170                .raw
171                .to_ascii_lowercase()
172                .cmp(&other.raw.to_ascii_lowercase()),
173        }
174    }
175}
176
177impl PartialOrd for Flag {
178    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
179        Some(self.cmp(other))
180    }
181}
182
183impl Hash for Flag {
184    /// Hashes the IANA tag first; falls back to the lowercase raw
185    /// bytes when none, so that values comparing equal hash equal.
186    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
187        match self.iana {
188            Some(iana) => {
189                0u8.hash(state);
190                iana.hash(state);
191            }
192            None => {
193                1u8.hash(state);
194                for b in self.raw.as_bytes() {
195                    b.to_ascii_lowercase().hash(state);
196                }
197            }
198        }
199    }
200}
201
202/// Classifies a wire flag spelling against the IANA keyword table.
203///
204/// Strips a single leading `\` or `$` prefix, lowercases the rest, and
205/// matches against the canonical name list. Returns `None` for custom
206/// user-defined keywords.
207pub fn classify_iana(raw: &str) -> Option<IanaFlag> {
208    let stripped = raw
209        .strip_prefix('\\')
210        .or_else(|| raw.strip_prefix('$'))
211        .unwrap_or(raw);
212
213    match stripped.to_ascii_lowercase().as_str() {
214        "seen" => Some(IanaFlag::Seen),
215        "answered" => Some(IanaFlag::Answered),
216        "flagged" => Some(IanaFlag::Flagged),
217        "draft" => Some(IanaFlag::Draft),
218        "deleted" => Some(IanaFlag::Deleted),
219        "forwarded" => Some(IanaFlag::Forwarded),
220        "junk" => Some(IanaFlag::Junk),
221        "notjunk" => Some(IanaFlag::NotJunk),
222        "phishing" => Some(IanaFlag::Phishing),
223        "important" => Some(IanaFlag::Important),
224        "mdnsent" => Some(IanaFlag::MdnSent),
225        _ => None,
226    }
227}
228
229/// Canonical wire spelling for each IANA keyword. The four RFC 3501
230/// system flags use the `\Capital` form; the rest use the `$Capital`
231/// form per the IANA mail keywords registry.
232fn canonical_raw(iana: IanaFlag) -> &'static str {
233    match iana {
234        IanaFlag::Seen => "\\Seen",
235        IanaFlag::Answered => "\\Answered",
236        IanaFlag::Flagged => "\\Flagged",
237        IanaFlag::Draft => "\\Draft",
238        IanaFlag::Deleted => "\\Deleted",
239        IanaFlag::Forwarded => "$Forwarded",
240        IanaFlag::Junk => "$Junk",
241        IanaFlag::NotJunk => "$NotJunk",
242        IanaFlag::Phishing => "$Phishing",
243        IanaFlag::Important => "$Important",
244        IanaFlag::MdnSent => "$MDNSent",
245    }
246}
247
248/// Direction of a flag store operation.
249///
250/// `Set` replaces the message's flag set with the given list; `Add`
251/// and `Remove` patch the existing set. Shared by `add_flags`,
252/// `set_flags` and `delete_flags`, surfaced on the per-backend
253/// flag-store coroutines.
254#[derive(Clone, Copy, Debug)]
255pub enum FlagOp {
256    Add,
257    Set,
258    Remove,
259}
260
261#[cfg(test)]
262mod tests {
263    use alloc::collections::BTreeSet;
264
265    use super::*;
266
267    #[test]
268    fn classify_strips_prefix_and_is_case_insensitive() {
269        assert_eq!(classify_iana("\\Seen"), Some(IanaFlag::Seen));
270        assert_eq!(classify_iana("$seen"), Some(IanaFlag::Seen));
271        assert_eq!(classify_iana("SEEN"), Some(IanaFlag::Seen));
272        assert_eq!(classify_iana("$MDNSent"), Some(IanaFlag::MdnSent));
273        assert_eq!(classify_iana("foo"), None);
274    }
275
276    #[test]
277    fn from_raw_populates_iana_when_recognised() {
278        let f = Flag::from_raw("\\Seen");
279        assert_eq!(f.raw(), "\\Seen");
280        assert_eq!(f.iana(), Some(IanaFlag::Seen));
281
282        let f = Flag::from_raw("custom-label");
283        assert_eq!(f.raw(), "custom-label");
284        assert_eq!(f.iana(), None);
285    }
286
287    #[test]
288    fn iana_uses_canonical_spelling() {
289        assert_eq!(Flag::from_iana(IanaFlag::Seen).raw(), "\\Seen");
290        assert_eq!(Flag::from_iana(IanaFlag::Forwarded).raw(), "$Forwarded");
291        assert_eq!(Flag::from_iana(IanaFlag::MdnSent).raw(), "$MDNSent");
292    }
293
294    #[test]
295    fn equality_collapses_wire_variants() {
296        assert_eq!(Flag::from_raw("\\Seen"), Flag::from_raw("$seen"));
297        assert_eq!(Flag::from_raw("\\Seen"), Flag::from_iana(IanaFlag::Seen));
298        assert_eq!(Flag::from_raw("FOO"), Flag::from_raw("foo"));
299        assert_ne!(Flag::from_raw("foo"), Flag::from_iana(IanaFlag::Seen));
300        assert_ne!(Flag::from_raw("foo"), Flag::from_raw("bar"));
301    }
302
303    #[test]
304    fn btreeset_dedupes_across_spellings() {
305        let mut set: BTreeSet<Flag> = BTreeSet::new();
306        set.insert(Flag::from_raw("\\Seen"));
307        set.insert(Flag::from_raw("$seen"));
308        set.insert(Flag::from_iana(IanaFlag::Seen));
309        set.insert(Flag::from_raw("custom"));
310        set.insert(Flag::from_raw("CUSTOM"));
311        assert_eq!(set.len(), 2);
312    }
313
314    #[test]
315    fn predicates_match_iana_only() {
316        assert!(Flag::from_iana(IanaFlag::Seen).is_seen());
317        assert!(!Flag::from_raw("seen-ish").is_seen());
318        assert!(Flag::from_iana(IanaFlag::Deleted).is_deleted());
319        assert!(Flag::from_iana(IanaFlag::Forwarded).is_forwarded());
320    }
321}