Skip to main content

jmap_mail_types/
keyword.rs

1//! System keyword constants and the [`Keyword`] newtype for JMAP Email (RFC 8621 §4.1.1).
2//!
3//! The [`Keyword`] type wraps a keyword string and serialises transparently as a
4//! JSON string.  The constants below are `&str` values for the IANA-registered
5//! system keywords.
6//!
7//! # Example
8//!
9//! ```rust
10//! use jmap_mail_types::Keyword;
11//!
12//! let kw = Keyword::from(jmap_mail_types::SEEN);
13//! // BTreeMap<Keyword, bool> also accepts &str via Borrow<str>:
14//! // let is_seen = email.keywords.contains_key(jmap_mail_types::SEEN);
15//! ```
16
17use std::borrow::Borrow;
18use std::fmt;
19use std::ops::Deref;
20
21use serde::{Deserialize, Serialize};
22
23/// Error returned by [`Keyword::try_new`] when the input violates RFC 8621 §4.1.1.
24#[non_exhaustive]
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum KeywordError {
27    /// The keyword string is empty.
28    Empty,
29    /// The keyword contains a character outside the allowed set
30    /// (RFC 8621 §4.1.1: visible ASCII, no whitespace).
31    InvalidChar(char),
32    /// The keyword starts with `$` but is not one of the IANA-registered system keywords.
33    /// Custom keywords MUST NOT begin with `$` (RFC 8621 §4.1.1).
34    ReservedPrefix,
35}
36
37impl fmt::Display for KeywordError {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            KeywordError::Empty => f.write_str("keyword must not be empty"),
41            KeywordError::InvalidChar(c) => write!(f, "keyword contains invalid character {:?}", c),
42            KeywordError::ReservedPrefix => f.write_str(
43                "custom keywords must not begin with '$' (reserved for system keywords)",
44            ),
45        }
46    }
47}
48
49impl std::error::Error for KeywordError {}
50
51/// IANA-registered system keywords (RFC 8621 §4.1.1 + IANA registry).
52/// Only these may begin with `$`. Custom keywords must not use the `$` prefix.
53const SYSTEM_KEYWORDS: &[&str] = &[
54    "$draft",
55    "$seen",
56    "$flagged",
57    "$answered",
58    "$forwarded",
59    "$phishing",
60    "$junk",
61    "$notjunk",
62];
63
64/// A JMAP keyword string (RFC 8621 §4.1.1).
65///
66/// Keywords are used to tag [`crate::Email`] objects.  System keywords begin
67/// with `$`; user-defined keywords must not.  The IANA-registered system
68/// keywords are available as `&str` constants in this module (e.g.
69/// [`SEEN`], [`FLAGGED`]).
70///
71/// `Keyword` serialises and deserialises transparently as a JSON string, so
72/// `BTreeMap<Keyword, bool>` round-trips correctly with the JMAP wire format.
73///
74/// Because `Keyword` implements `Borrow<str>`, a `BTreeMap<Keyword, bool>` can
75/// be queried with a bare `&str`:
76///
77/// ```rust
78/// use jmap_mail_types::{Keyword};
79/// use std::collections::BTreeMap;
80///
81/// let mut kw: BTreeMap<Keyword, bool> = BTreeMap::new();
82/// kw.insert(Keyword::from(jmap_mail_types::SEEN), true);
83/// assert!(kw.contains_key(jmap_mail_types::SEEN));
84/// ```
85///
86/// No syntax validation is performed at construction time — keyword syntax
87/// validation is the server's responsibility.
88#[non_exhaustive]
89#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
90#[serde(transparent)]
91pub struct Keyword(String);
92
93impl Keyword {
94    /// Construct a [`Keyword`] from a string without validation.
95    pub fn new(s: impl Into<String>) -> Self {
96        Keyword(s.into())
97    }
98
99    /// Construct a [`Keyword`] with RFC 8621 §4.1.1 syntax validation.
100    ///
101    /// Returns `Err(KeywordError)` if the input:
102    /// - is empty,
103    /// - contains whitespace or non-visible-ASCII characters,
104    /// - starts with `$` but is not one of the IANA-registered system keywords.
105    ///
106    /// Use [`Keyword::new`] if you need an infallible constructor (e.g., when
107    /// deserializing from JSON where the server guarantees validity).
108    pub fn try_new(s: impl Into<String>) -> Result<Self, KeywordError> {
109        let s: String = s.into();
110        if s.is_empty() {
111            return Err(KeywordError::Empty);
112        }
113        // Check each character: must be visible ASCII (0x21–0x7E), no whitespace.
114        for ch in s.chars() {
115            let b = ch as u32;
116            if !(0x21..=0x7E).contains(&b) {
117                return Err(KeywordError::InvalidChar(ch));
118            }
119        }
120        // If starts with '$', must be a known system keyword (case-insensitive compare).
121        if s.starts_with('$') {
122            let lower = s.to_ascii_lowercase();
123            if !SYSTEM_KEYWORDS.contains(&lower.as_str()) {
124                return Err(KeywordError::ReservedPrefix);
125            }
126        }
127        Ok(Keyword(s))
128    }
129}
130
131impl From<&str> for Keyword {
132    fn from(s: &str) -> Self {
133        Keyword(s.to_owned())
134    }
135}
136
137impl From<String> for Keyword {
138    fn from(s: String) -> Self {
139        Keyword(s)
140    }
141}
142
143impl AsRef<str> for Keyword {
144    fn as_ref(&self) -> &str {
145        &self.0
146    }
147}
148
149impl Borrow<str> for Keyword {
150    fn borrow(&self) -> &str {
151        &self.0
152    }
153}
154
155impl Deref for Keyword {
156    type Target = str;
157    fn deref(&self) -> &str {
158        &self.0
159    }
160}
161
162impl fmt::Display for Keyword {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        f.write_str(&self.0)
165    }
166}
167
168/// The Email is a draft being composed by the user (RFC 8621 §4.1.1).
169pub const DRAFT: &str = "$draft";
170
171/// The Email has been read (RFC 8621 §4.1.1).
172pub const SEEN: &str = "$seen";
173
174/// The Email has been flagged for urgent/special attention (RFC 8621 §4.1.1).
175pub const FLAGGED: &str = "$flagged";
176
177/// The Email has been replied to (RFC 8621 §4.1.1).
178pub const ANSWERED: &str = "$answered";
179
180/// The Email has been forwarded (RFC 8621 §4.1.1 / IANA registry).
181pub const FORWARDED: &str = "$forwarded";
182
183/// The Email is highly likely to be phishing (RFC 8621 §4.1.1 / IANA registry).
184pub const PHISHING: &str = "$phishing";
185
186/// The Email is definitely spam (RFC 8621 §4.1.1 / IANA registry).
187pub const JUNK: &str = "$junk";
188
189/// The Email is definitely not spam (RFC 8621 §4.1.1 / IANA registry).
190pub const NOT_JUNK: &str = "$notjunk";
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    /// Oracle: RFC 8621 §4.1.1 — keyword wire strings include the leading `$`.
197    #[test]
198    fn keyword_wire_strings() {
199        assert_eq!(DRAFT, "$draft");
200        assert_eq!(SEEN, "$seen");
201        assert_eq!(FLAGGED, "$flagged");
202        assert_eq!(ANSWERED, "$answered");
203        assert_eq!(FORWARDED, "$forwarded");
204        assert_eq!(PHISHING, "$phishing");
205        assert_eq!(JUNK, "$junk");
206        assert_eq!(NOT_JUNK, "$notjunk");
207    }
208
209    /// Oracle: Keyword implements Borrow<str> so BTreeMap<Keyword, bool>
210    /// can be queried with a bare &str constant.
211    #[test]
212    fn keyword_usable_as_hashmap_key() {
213        let mut keywords = std::collections::BTreeMap::new();
214        keywords.insert(Keyword::from(SEEN), true);
215        keywords.insert(Keyword::from(FLAGGED), true);
216        assert!(keywords.contains_key(SEEN));
217        assert!(keywords.contains_key(FLAGGED));
218        assert!(!keywords.contains_key(DRAFT));
219    }
220
221    /// Oracle: Keyword serialises transparently as a JSON string.
222    #[test]
223    fn keyword_roundtrips_as_json_string() {
224        let kw = Keyword::from(SEEN);
225        let json = serde_json::to_string(&kw).expect("serialize");
226        assert_eq!(json, "\"$seen\"");
227        let back: Keyword = serde_json::from_str(&json).expect("deserialize");
228        assert_eq!(back, kw);
229    }
230
231    /// Oracle: Keyword::new and From impls produce equal values.
232    #[test]
233    fn keyword_construction() {
234        let a = Keyword::new("$seen");
235        let b = Keyword::from("$seen");
236        let c = Keyword::from("$seen".to_owned());
237        assert_eq!(a, b);
238        assert_eq!(b, c);
239        assert_eq!(a.as_ref(), "$seen");
240        assert_eq!(a.to_string(), "$seen");
241    }
242
243    // --- KeywordError and try_new tests ---
244
245    /// Oracle: RFC 8621 §4.1.1 — empty keyword is invalid.
246    #[test]
247    fn try_new_empty_fails() {
248        assert_eq!(Keyword::try_new(""), Err(KeywordError::Empty));
249    }
250
251    /// Oracle: RFC 8621 §4.1.1 — whitespace is not visible ASCII.
252    #[test]
253    fn try_new_space_fails() {
254        let err = Keyword::try_new("has space").unwrap_err();
255        assert_eq!(err, KeywordError::InvalidChar(' '));
256    }
257
258    /// Oracle: RFC 8621 §4.1.1 — tab is not visible ASCII.
259    #[test]
260    fn try_new_tab_fails() {
261        let err = Keyword::try_new("has\ttab").unwrap_err();
262        assert_eq!(err, KeywordError::InvalidChar('\t'));
263    }
264
265    /// Oracle: control character is not visible ASCII.
266    #[test]
267    fn try_new_control_char_fails() {
268        let err = Keyword::try_new("has\x01ctrl").unwrap_err();
269        assert_eq!(err, KeywordError::InvalidChar('\x01'));
270    }
271
272    /// Oracle: RFC 8621 §4.1.1 — '$' prefix reserved for system keywords.
273    #[test]
274    fn try_new_unknown_dollar_prefix_fails() {
275        let err = Keyword::try_new("$unknown").unwrap_err();
276        assert_eq!(err, KeywordError::ReservedPrefix);
277    }
278
279    /// Oracle: RFC 8621 §4.1.1 — all 8 IANA system keywords must be accepted.
280    #[test]
281    fn try_new_all_system_keywords_succeed() {
282        for kw in &[
283            "$draft",
284            "$seen",
285            "$flagged",
286            "$answered",
287            "$forwarded",
288            "$phishing",
289            "$junk",
290            "$notjunk",
291        ] {
292            Keyword::try_new(*kw)
293                .unwrap_or_else(|e| panic!("system keyword {kw:?} must succeed: {e}"));
294        }
295    }
296
297    /// Oracle: system keywords are case-insensitive per RFC 8621 §4.1.1.
298    #[test]
299    fn try_new_system_keyword_case_insensitive() {
300        // RFC 8621 §4.1.1: "Keywords are case-insensitive"
301        // $SEEN should be accepted (it maps to the $seen system keyword).
302        Keyword::try_new("$SEEN").expect("$SEEN (case variant of $seen) must succeed");
303    }
304
305    /// Oracle: RFC 8621 §4.1.1 — valid custom keyword (no $) succeeds.
306    #[test]
307    fn try_new_custom_keyword_succeeds() {
308        let kw = Keyword::try_new("project-alpha").expect("valid custom keyword must succeed");
309        assert_eq!(kw.as_ref(), "project-alpha");
310    }
311
312    /// Oracle: KeywordError implements std::error::Error.
313    #[test]
314    fn keyword_error_implements_error() {
315        let e = Keyword::try_new("").unwrap_err();
316        let _: &dyn std::error::Error = &e;
317        assert!(!e.to_string().is_empty());
318    }
319}