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