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