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