Skip to main content

jmap_mail_types/
query.rs

1//! Email/query filter and comparator types (RFC 8621 §4.4).
2//!
3//! Provides [`EmailFilterCondition`] — the mail-specific condition object for
4//! Email/query — and the [`EmailFilter`] type alias for convenience.
5//!
6//! The generic [`Filter`], [`FilterOperator`], and [`Operator`] types used here
7//! are defined in `jmap-types::query` (RFC 8620 §5.5).
8
9use jmap_types::{Id, UTCDate};
10use serde::{Deserialize, Serialize};
11
12use crate::keyword::Keyword;
13
14pub use jmap_types::query::{Filter, FilterOperator, Operator};
15
16/// Concrete filter type for Email/query (RFC 8621 §4.4).
17pub type EmailFilter = Filter<EmailFilterCondition>;
18
19/// Concrete filter type for EmailSubmission/query (RFC 8621 §7.3).
20///
21/// The condition struct ([`crate::submission::EmailSubmissionFilterCondition`])
22/// lives in [`crate::submission`] alongside the other EmailSubmission types.
23pub type EmailSubmissionFilter = Filter<crate::submission::EmailSubmissionFilterCondition>;
24
25/// Filter condition for Email objects (RFC 8621 §4.4.1).
26///
27/// All fields are optional.  If zero properties are specified, the condition
28/// evaluates to `true` for every Email (RFC 8621 §4.4.1).  When multiple
29/// properties are specified, ALL must apply (equivalent to splitting into
30/// one-property conditions under AND).
31///
32/// Do not add `#[serde(deny_unknown_fields)]` — it breaks `#[serde(untagged)]`
33/// deserialization when `EmailFilterCondition` is used inside `Filter<T>`.
34///
35/// # Construction from outside this crate
36///
37/// The struct is `#[non_exhaustive]`: struct literal syntax and functional
38/// record update (`{ field: val, ..Default::default() }`) are unavailable to
39/// external callers.  Use [`Default::default`] and then mutate the fields you
40/// need:
41///
42/// ```rust
43/// use jmap_mail_types::query::EmailFilterCondition;
44/// use jmap_mail_types::{keyword, Keyword};
45/// use jmap_types::Id;
46///
47/// let mut cond = EmailFilterCondition::default();
48/// cond.in_mailbox = Some(Id::from("inbox-id"));
49/// cond.has_keyword = Some(Keyword::from(keyword::SEEN));
50/// ```
51#[non_exhaustive]
52#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct EmailFilterCondition {
55    /// A Mailbox id; the Email must be in this Mailbox.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub in_mailbox: Option<Id>,
58
59    /// A list of Mailbox ids; the Email must be in at least one Mailbox not in
60    /// this list (used to exclude trash/spam from results).
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub in_mailbox_other_than: Option<Vec<Id>>,
63
64    /// The `receivedAt` of the Email must be before this date-time.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub before: Option<UTCDate>,
67
68    /// The `receivedAt` of the Email must be on or after this date-time.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub after: Option<UTCDate>,
71
72    /// The `size` of the Email must be >= this value (bytes).
73    ///
74    /// RFC 8620 §1.3 defines `UnsignedInt` as limited to the range
75    /// [0, 2^53-1].  Values above that threshold may not round-trip correctly
76    /// through JSON parsers that use IEEE 754 doubles.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub min_size: Option<u64>,
79
80    /// The `size` of the Email must be < this value (bytes).
81    ///
82    /// Same 2^53-1 constraint as `min_size` (RFC 8620 §1.3).
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub max_size: Option<u64>,
85
86    /// All Emails in the same Thread must have this keyword.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub all_in_thread_have_keyword: Option<Keyword>,
89
90    /// At least one Email in the same Thread must have this keyword.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub some_in_thread_have_keyword: Option<Keyword>,
93
94    /// No Email in the same Thread may have this keyword.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub none_in_thread_have_keyword: Option<Keyword>,
97
98    /// This Email must have this keyword.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub has_keyword: Option<Keyword>,
101
102    /// This Email must not have this keyword.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub not_keyword: Option<Keyword>,
105
106    /// The `hasAttachment` property of the Email must equal this value.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub has_attachment: Option<bool>,
109
110    /// Matches text across From, To, Cc, Bcc, Subject, and body parts.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub text: Option<String>,
113
114    /// Matches text in the From header field.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub from: Option<String>,
117
118    /// Matches text in the To header field.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub to: Option<String>,
121
122    /// Matches text in the Cc header field.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub cc: Option<String>,
125
126    /// Matches text in the Bcc header field.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub bcc: Option<String>,
129
130    /// Matches text in the Subject header field.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub subject: Option<String>,
133
134    /// Matches text in a body part of the message.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub body: Option<String>,
137
138    /// Arbitrary header field match.  RFC 8621 §4.4.1 requires exactly 1 or 2
139    /// elements: the first is the header field name; the second (optional) is
140    /// the value to match.
141    ///
142    /// **Invariant**: when `Some`, the `Vec` must have exactly 1 or 2 elements.
143    /// This is enforced at deserialization time (supplying 0 or 3+ elements is
144    /// rejected with an error).  Code that constructs an
145    /// `EmailFilterCondition` directly and sets `header` is responsible for
146    /// upholding this invariant; serialization does not re-validate.
147    #[serde(
148        default,
149        skip_serializing_if = "Option::is_none",
150        deserialize_with = "deserialize_header"
151    )]
152    pub header: Option<Vec<String>>,
153}
154
155// ---------------------------------------------------------------------------
156// ComparatorProperty (RFC 8621 §4.4.2)
157// ---------------------------------------------------------------------------
158
159/// The property to sort by in an [`EmailComparator`] (RFC 8621 §4.4.2).
160///
161/// When the variant is [`HasKeyword`](ComparatorProperty::HasKeyword),
162/// [`AllInThreadHaveKeyword`](ComparatorProperty::AllInThreadHaveKeyword), or
163/// [`SomeInThreadHaveKeyword`](ComparatorProperty::SomeInThreadHaveKeyword),
164/// the `keyword` field on [`EmailComparator`] **MUST** also be set
165/// (RFC 8621 §4.4.2).
166///
167/// Unknown property names from the server are preserved in
168/// [`Other`](ComparatorProperty::Other) so they round-trip correctly.
169#[non_exhaustive]
170#[derive(Debug, Clone, PartialEq, Eq, Hash)]
171pub enum ComparatorProperty {
172    /// Sort by `receivedAt`.
173    ReceivedAt,
174    /// Sort by message size in octets.
175    Size,
176    /// Sort by the text of the From header field.
177    From,
178    /// Sort by the text of the To header field.
179    To,
180    /// Sort by the decoded Subject.
181    Subject,
182    /// Sort by `sentAt`.
183    SentAt,
184    /// Sort by whether Emails in the Thread have the given `keyword`.
185    HasKeyword,
186    /// Sort by whether all Emails in the Thread have the given `keyword`.
187    AllInThreadHaveKeyword,
188    /// Sort by whether some Emails in the Thread have the given `keyword`.
189    SomeInThreadHaveKeyword,
190    /// A server-extension property name not listed above.
191    Other(String),
192}
193
194impl_string_enum!(ComparatorProperty, "an Email comparator property string",
195    "receivedAt"              => ReceivedAt,
196    "size"                    => Size,
197    "from"                    => From,
198    "to"                      => To,
199    "subject"                 => Subject,
200    "sentAt"                  => SentAt,
201    "hasKeyword"              => HasKeyword,
202    "allInThreadHaveKeyword"  => AllInThreadHaveKeyword,
203    "someInThreadHaveKeyword" => SomeInThreadHaveKeyword,
204);
205
206// ---------------------------------------------------------------------------
207// EmailComparator (RFC 8621 §4.4.2)
208// ---------------------------------------------------------------------------
209
210/// Sort comparator for Email/query (RFC 8621 §4.4.2).
211///
212/// When `property` is one of the keyword-based variants
213/// ([`ComparatorProperty::HasKeyword`], [`ComparatorProperty::AllInThreadHaveKeyword`],
214/// [`ComparatorProperty::SomeInThreadHaveKeyword`]), the `keyword` field
215/// **MUST** also be set.  `is_ascending` defaults to `true` per RFC 8620 §5.5.
216///
217/// # Construction
218///
219/// Use [`EmailComparator::new`] to construct from outside this crate.
220/// The struct is `#[non_exhaustive]`; struct literal syntax is not available
221/// to external callers.
222///
223/// ```rust
224/// use jmap_mail_types::query::{EmailComparator, ComparatorProperty};
225///
226/// let mut cmp = EmailComparator::new(ComparatorProperty::ReceivedAt);
227/// cmp.is_ascending = false;  // sort descending
228/// ```
229#[non_exhaustive]
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232pub struct EmailComparator {
233    /// The property to sort by.
234    pub property: ComparatorProperty,
235
236    /// If `true`, sort ascending; if `false`, sort descending.
237    /// Defaults to `true` per RFC 8620 §5.5.
238    #[serde(default = "bool_true", skip_serializing_if = "is_true")]
239    pub is_ascending: bool,
240
241    /// Collation algorithm (e.g. `"i;ascii-casemap"`).
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub collation: Option<String>,
244
245    /// Required when `property` is one of the keyword-based variants
246    /// (RFC 8621 §4.4.2).
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub keyword: Option<Keyword>,
249}
250
251impl EmailComparator {
252    /// Construct an [`EmailComparator`] for the given property.
253    ///
254    /// `is_ascending` defaults to `true` (RFC 8620 §5.5 default).
255    /// `collation` and `keyword` default to `None`.
256    ///
257    /// Set fields directly after construction:
258    ///
259    /// ```rust
260    /// use jmap_mail_types::query::{EmailComparator, ComparatorProperty};
261    /// use jmap_mail_types::Keyword;
262    ///
263    /// let mut cmp = EmailComparator::new(ComparatorProperty::HasKeyword);
264    /// cmp.keyword = Some(Keyword::from("$flagged"));
265    /// cmp.is_ascending = false;
266    /// ```
267    pub fn new(property: ComparatorProperty) -> Self {
268        Self {
269            property,
270            is_ascending: true,
271            collation: None,
272            keyword: None,
273        }
274    }
275}
276
277fn bool_true() -> bool {
278    true
279}
280
281fn is_true(b: &bool) -> bool {
282    *b
283}
284
285// ---------------------------------------------------------------------------
286// Deserializer helpers
287// ---------------------------------------------------------------------------
288
289/// Deserialize `header` and enforce the 1–2 element constraint from RFC 8621 §4.4.1.
290fn deserialize_header<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
291where
292    D: serde::Deserializer<'de>,
293{
294    let v: Option<Vec<String>> = Option::deserialize(deserializer)?;
295    if let Some(ref h) = v {
296        if h.is_empty() || h.len() > 2 {
297            return Err(serde::de::Error::custom(format!(
298                "header must have 1 or 2 elements (RFC 8621 §4.4.1), got {}",
299                h.len()
300            )));
301        }
302    }
303    Ok(v)
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    /// Oracle: RFC 8621 §4.4 example — filter by Mailbox id.
311    /// Wire format from RFC 8621 §4.4 (line 3240 area of the RFC text).
312    #[test]
313    fn filter_condition_in_mailbox() {
314        let json = r#"{"inMailbox":"fb666a55"}"#;
315        let f: EmailFilter = serde_json::from_str(json).expect("must parse");
316        match &f {
317            Filter::Condition(c) => {
318                assert_eq!(
319                    c.in_mailbox.as_ref().map(|id| id.as_ref()),
320                    Some("fb666a55")
321                );
322            }
323            other => panic!("expected Condition, got {other:?}"),
324        }
325        let back = serde_json::to_string(&f).expect("serialize");
326        assert_eq!(back, json);
327    }
328
329    /// Oracle: RFC 8620 §5.5 example — OR of two keyword conditions applied to Email.
330    /// Adapted from the Todo/query example in RFC 8620 §5.5.
331    #[test]
332    fn filter_operator_or_two_keywords() {
333        let json =
334            r#"{"operator":"OR","conditions":[{"hasKeyword":"music"},{"hasKeyword":"video"}]}"#;
335        let f: EmailFilter = serde_json::from_str(json).expect("must parse");
336        match &f {
337            Filter::Operator(op) => {
338                assert_eq!(op.operator, Operator::Or);
339                assert_eq!(op.conditions.len(), 2);
340                match &op.conditions[0] {
341                    Filter::Condition(c) => {
342                        assert_eq!(c.has_keyword.as_deref(), Some("music"))
343                    }
344                    other => panic!("expected Condition, got {other:?}"),
345                }
346                match &op.conditions[1] {
347                    Filter::Condition(c) => {
348                        assert_eq!(c.has_keyword.as_deref(), Some("video"))
349                    }
350                    other => panic!("expected Condition, got {other:?}"),
351                }
352            }
353            other => panic!("expected Operator, got {other:?}"),
354        }
355        let back = serde_json::to_string(&f).expect("serialize");
356        assert_eq!(back, json);
357    }
358
359    /// Oracle: nested AND(OR(...)) structure roundtrips.
360    #[test]
361    fn nested_and_or_roundtrip() {
362        let filter = EmailFilter::Operator(FilterOperator::new(
363            Operator::And,
364            vec![
365                EmailFilter::Condition(EmailFilterCondition {
366                    in_mailbox: Some(Id::from("inbox-id")),
367                    ..Default::default()
368                }),
369                EmailFilter::Operator(FilterOperator::new(
370                    Operator::Or,
371                    vec![
372                        EmailFilter::Condition(EmailFilterCondition {
373                            has_keyword: Some(Keyword::from("$flagged")),
374                            ..Default::default()
375                        }),
376                        EmailFilter::Condition(EmailFilterCondition {
377                            has_keyword: Some(Keyword::from("$answered")),
378                            ..Default::default()
379                        }),
380                    ],
381                )),
382            ],
383        ));
384        let json = serde_json::to_string(&filter).expect("serialize");
385        let back: EmailFilter = serde_json::from_str(&json).expect("deserialize");
386        assert_eq!(filter, back);
387    }
388
389    /// Oracle: empty EmailFilterCondition serializes to `{}` and omits all fields.
390    #[test]
391    fn empty_condition_serializes_to_empty_object() {
392        let c = EmailFilterCondition::default();
393        let json = serde_json::to_string(&c).expect("serialize");
394        assert_eq!(json, "{}");
395    }
396
397    /// Oracle: all scalar fields roundtrip correctly.
398    #[test]
399    fn all_scalar_fields_roundtrip() {
400        let c = EmailFilterCondition {
401            in_mailbox: Some(Id::from("mb1")),
402            min_size: Some(1024),
403            max_size: Some(65536),
404            all_in_thread_have_keyword: Some(Keyword::from("$seen")),
405            some_in_thread_have_keyword: Some(Keyword::from("$flagged")),
406            none_in_thread_have_keyword: Some(Keyword::from("$draft")),
407            has_keyword: Some(Keyword::from("$answered")),
408            not_keyword: Some(Keyword::from("$junk")),
409            has_attachment: Some(true),
410            text: Some("hello".to_owned()),
411            from: Some("alice@example.com".to_owned()),
412            to: Some("bob@example.com".to_owned()),
413            cc: Some("carol@example.com".to_owned()),
414            bcc: Some("dave@example.com".to_owned()),
415            subject: Some("Meeting".to_owned()),
416            body: Some("agenda".to_owned()),
417            ..Default::default()
418        };
419        let json = serde_json::to_string(&c).expect("serialize");
420        let back: EmailFilterCondition = serde_json::from_str(&json).expect("deserialize");
421        assert_eq!(c, back);
422    }
423
424    /// Oracle: `header` with one element (field name only) is accepted.
425    #[test]
426    fn header_one_element_accepted() {
427        let json = r#"{"header":["X-Spam-Status"]}"#;
428        let c: EmailFilterCondition = serde_json::from_str(json).expect("must parse");
429        let h = c.header.as_ref().expect("header must be present");
430        assert_eq!(h.len(), 1);
431        assert_eq!(h[0], "X-Spam-Status");
432    }
433
434    /// Oracle: `header` with two elements (field name + value) is accepted.
435    #[test]
436    fn header_two_elements_accepted() {
437        let json = r#"{"header":["X-Spam-Status","Yes"]}"#;
438        let c: EmailFilterCondition = serde_json::from_str(json).expect("must parse");
439        let h = c.header.as_ref().expect("header must be present");
440        assert_eq!(h.len(), 2);
441        assert_eq!(h[0], "X-Spam-Status");
442        assert_eq!(h[1], "Yes");
443    }
444
445    /// Oracle: RFC 8621 §4.4.1 — `header` with zero elements is a protocol error.
446    #[test]
447    fn header_zero_elements_rejected() {
448        let json = r#"{"header":[]}"#;
449        let err = serde_json::from_str::<EmailFilterCondition>(json)
450            .expect_err("0-element header must fail");
451        let msg = err.to_string();
452        assert!(
453            msg.contains("header must have 1 or 2 elements"),
454            "unexpected error: {msg}"
455        );
456    }
457
458    /// Oracle: RFC 8621 §4.4.1 — `header` with three elements is a protocol error.
459    #[test]
460    fn header_three_elements_rejected() {
461        let json = r#"{"header":["X-Foo","bar","extra"]}"#;
462        let err = serde_json::from_str::<EmailFilterCondition>(json)
463            .expect_err("3-element header must fail");
464        let msg = err.to_string();
465        assert!(
466            msg.contains("header must have 1 or 2 elements"),
467            "unexpected error: {msg}"
468        );
469    }
470
471    /// Oracle: `before` and `after` fields use UTCDate wire format (RFC 3339 UTC).
472    #[test]
473    fn date_fields_roundtrip() {
474        let json = r#"{"before":"2024-01-15T12:00:00Z","after":"2024-01-01T00:00:00Z"}"#;
475        let c: EmailFilterCondition = serde_json::from_str(json).expect("must parse");
476        let back = serde_json::to_string(&c).expect("serialize");
477        assert_eq!(back, json);
478    }
479
480    /// Oracle: `inMailboxOtherThan` is a list of Ids.
481    #[test]
482    fn in_mailbox_other_than_roundtrip() {
483        let json = r#"{"inMailboxOtherThan":["trash-id","spam-id"]}"#;
484        let c: EmailFilterCondition = serde_json::from_str(json).expect("must parse");
485        let ids: Vec<&str> = c
486            .in_mailbox_other_than
487            .as_ref()
488            .unwrap()
489            .iter()
490            .map(|id| id.as_ref())
491            .collect();
492        assert_eq!(ids, ["trash-id", "spam-id"]);
493        let back = serde_json::to_string(&c).expect("serialize");
494        assert_eq!(back, json);
495    }
496
497    // ---------------------------------------------------------------------------
498    // EmailComparator tests
499    // ---------------------------------------------------------------------------
500
501    /// Oracle: RFC 8621 §4.4.2 example — three-comparator sort array.
502    /// Wire format taken verbatim from the RFC 8621 §4.4.2 example.
503    #[test]
504    fn comparator_example_from_rfc() {
505        // Three comparators from RFC 8621 §4.4.2.
506        // First: keyword sort (isAscending=false, keyword present).
507        let json0 =
508            r#"{"property":"someInThreadHaveKeyword","keyword":"$flagged","isAscending":false}"#;
509        let c0: EmailComparator = serde_json::from_str(json0).expect("must parse");
510        assert_eq!(c0.property, ComparatorProperty::SomeInThreadHaveKeyword);
511        assert_eq!(c0.keyword.as_deref(), Some("$flagged"));
512        assert!(!c0.is_ascending);
513
514        // Second: subject sort with collation, isAscending defaults to true (omitted).
515        let json1 = r#"{"property":"subject","collation":"i;ascii-casemap"}"#;
516        let c1: EmailComparator = serde_json::from_str(json1).expect("must parse");
517        assert_eq!(c1.property, ComparatorProperty::Subject);
518        assert_eq!(c1.collation.as_deref(), Some("i;ascii-casemap"));
519        assert!(c1.is_ascending);
520        // isAscending is true (default) and must be omitted on serialization.
521        let back1 = serde_json::to_string(&c1).expect("serialize");
522        assert_eq!(back1, json1);
523
524        // Third: receivedAt descending.
525        let json2 = r#"{"property":"receivedAt","isAscending":false}"#;
526        let c2: EmailComparator = serde_json::from_str(json2).expect("must parse");
527        assert_eq!(c2.property, ComparatorProperty::ReceivedAt);
528        assert!(!c2.is_ascending);
529        let back2 = serde_json::to_string(&c2).expect("serialize");
530        assert_eq!(back2, json2);
531    }
532
533    /// Oracle: isAscending defaults to true when absent; omitted from serialization
534    /// when true (RFC 8620 §5.5 default).
535    #[test]
536    fn comparator_is_ascending_default_and_skip() {
537        let json = r#"{"property":"receivedAt"}"#;
538        let c: EmailComparator = serde_json::from_str(json).expect("must parse");
539        assert!(c.is_ascending, "default must be true");
540        let back = serde_json::to_string(&c).expect("serialize");
541        assert_eq!(back, json, "isAscending:true must be omitted");
542    }
543
544    // ---------------------------------------------------------------------------
545    // Filter ambiguity tests (JMAP-amk.2)
546    // ---------------------------------------------------------------------------
547
548    /// Oracle: RFC 8621 §4.4 — a JSON object with no recognized fields deserializes
549    /// as Filter::Condition with all fields None.  RFC 8621 §4.4.1 states "if zero
550    /// properties are specified, the condition MUST always evaluate to true."
551    /// The untagged enum tries FilterOperator first (fails: no "operator" key),
552    /// then falls through to Condition(T) with all-None fields.
553    #[test]
554    fn filter_unknown_fields_become_empty_condition() {
555        let json = r#"{"unknownField":"value"}"#;
556        let f: EmailFilter = serde_json::from_str(json).expect("must parse as empty Condition");
557        match &f {
558            Filter::Condition(c) => {
559                assert_eq!(
560                    *c,
561                    EmailFilterCondition::default(),
562                    "unknown fields yield all-None"
563                );
564            }
565            other => panic!("expected empty Condition, got {other:?}"),
566        }
567    }
568
569    /// Oracle: an empty JSON object `{}` produces an empty condition (all None),
570    /// which matches every Email per RFC 8621 §4.4.1.
571    #[test]
572    fn filter_empty_object_becomes_empty_condition() {
573        let json = "{}";
574        let f: EmailFilter = serde_json::from_str(json).expect("must parse");
575        assert!(
576            matches!(&f, Filter::Condition(c) if c == &EmailFilterCondition::default()),
577            "empty object must yield all-None Condition"
578        );
579    }
580
581    /// Oracle: EmailComparator::new produces expected defaults and correct JSON.
582    /// RFC 8620 §5.5 — isAscending defaults to true and is omitted when true.
583    #[test]
584    fn comparator_new_constructor() {
585        let cmp = EmailComparator::new(ComparatorProperty::ReceivedAt);
586        assert_eq!(cmp.property, ComparatorProperty::ReceivedAt);
587        assert!(cmp.is_ascending);
588        assert!(cmp.collation.is_none());
589        assert!(cmp.keyword.is_none());
590        // isAscending:true is the default and must be omitted on the wire.
591        let json = serde_json::to_string(&cmp).expect("serialize");
592        assert_eq!(json, r#"{"property":"receivedAt"}"#);
593    }
594
595    /// Oracle: EmailComparator::new with field mutation produces correct JSON.
596    /// RFC 8621 §4.4.2 — keyword-based comparator must include keyword field.
597    #[test]
598    fn comparator_new_with_mutation() {
599        let mut cmp = EmailComparator::new(ComparatorProperty::HasKeyword);
600        cmp.keyword = Some(Keyword::from("$flagged"));
601        cmp.is_ascending = false;
602        let json = serde_json::to_string(&cmp).expect("serialize");
603        assert_eq!(
604            json,
605            r#"{"property":"hasKeyword","isAscending":false,"keyword":"$flagged"}"#
606        );
607    }
608}