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}