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` (RFC 8620 §5.5).
8
9use jmap_metadata_types::MetadataTextMatch;
10use jmap_types::{Id, UTCDate, impl_string_enum};
11use serde::{Deserialize, Serialize};
12
13use crate::keyword::Keyword;
14
15pub use jmap_types::{Filter, FilterOperator, Operator};
16
17/// Concrete filter type for Email/query (RFC 8621 §4.4).
18pub type EmailFilter = Filter<EmailFilterCondition>;
19
20/// Concrete filter type for Mailbox/query (RFC 8621 §2.3).
21///
22/// The condition struct is [`MailboxFilterCondition`](crate::MailboxFilterCondition).
23pub type MailboxFilter = Filter<crate::mailbox::MailboxFilterCondition>;
24
25/// Concrete filter type for EmailSubmission/query (RFC 8621 §7.3).
26///
27/// The condition struct is
28/// [`EmailSubmissionFilterCondition`](crate::EmailSubmissionFilterCondition).
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 listed 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-04 §1.2.1, §3.6). 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::EmailFilterCondition;
83/// use jmap_mail_types::{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(jmap_mail_types::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    /// draft-ietf-jmap-metadata-04 §3.6 `metadataExists`: a value must be
194    /// present at this path in the object's `metadata`. The path is a
195    /// namespace identifier, optionally followed by an RFC 6901 JSON
196    /// Pointer into the namespace value.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub metadata_exists: Option<String>,
199
200    /// -04 §3.6 `privateMetadataExists`: as `metadata_exists`, against the
201    /// authenticated user's `privateMetadata`.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub private_metadata_exists: Option<String>,
204
205    /// -04 §3.6 `metadataTextContains`: substring match under the
206    /// `i;unicode-casemap` collation on the `metadata` string at a path.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub metadata_text_contains: Option<MetadataTextMatch>,
209
210    /// -04 §3.6 `privateMetadataTextContains`: as
211    /// `metadata_text_contains`, against `privateMetadata`.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub private_metadata_text_contains: Option<MetadataTextMatch>,
214
215    /// -04 §3.6 `metadataTextEquals`: exact code-point match on the
216    /// `metadata` string at a path.
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub metadata_text_equals: Option<MetadataTextMatch>,
219
220    /// -04 §3.6 `privateMetadataTextEquals`: as `metadata_text_equals`,
221    /// against `privateMetadata`.
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub private_metadata_text_equals: Option<MetadataTextMatch>,
224}
225
226impl EmailFilterCondition {
227    /// `true` if any of the six draft-ietf-jmap-metadata-04 §3.6
228    /// `metadata*` / `privateMetadata*` fields is set.
229    pub fn has_metadata_condition(&self) -> bool {
230        self.metadata_exists.is_some()
231            || self.private_metadata_exists.is_some()
232            || self.metadata_text_contains.is_some()
233            || self.private_metadata_text_contains.is_some()
234            || self.metadata_text_equals.is_some()
235            || self.private_metadata_text_equals.is_some()
236    }
237}
238
239// ---------------------------------------------------------------------------
240// ComparatorProperty (RFC 8621 §4.4.2)
241// ---------------------------------------------------------------------------
242
243/// The property to sort by in an [`EmailComparator`] (RFC 8621 §4.4.2).
244///
245/// When the variant is [`HasKeyword`](ComparatorProperty::HasKeyword),
246/// [`AllInThreadHaveKeyword`](ComparatorProperty::AllInThreadHaveKeyword), or
247/// [`SomeInThreadHaveKeyword`](ComparatorProperty::SomeInThreadHaveKeyword),
248/// the `keyword` field on [`EmailComparator`] **MUST** also be set
249/// (RFC 8621 §4.4.2).
250///
251/// Unknown property names from the server are preserved in
252/// [`Other`](ComparatorProperty::Other) so they round-trip correctly.
253///
254/// # Excluded from extras preservation
255///
256/// This enum is **out of scope** for the workspace extras-preservation
257/// policy: it is a control enum that backends must dispatch on to
258/// determine the sort order, so a generic catch-all variant would be
259/// meaningless for query execution. The `Other(String)` variant that
260/// `ComparatorProperty` does carry exists only to preserve unknown
261/// property names for client-side round-tripping; servers cannot
262/// meaningfully sort by an unrecognised property without a registered
263/// extension.
264///
265/// More broadly, filter / comparator algebra is excluded because
266/// unrecognised clauses are a query-correctness hazard: silently dropping
267/// or round-tripping a clause the server does not understand can return
268/// the wrong set of records to the client without any error signal.
269///
270/// ## What to do instead
271///
272/// **IETF-track path.** Vendors who need both capability-level declaration
273/// and filterability for custom fields should use
274/// `draft-ietf-jmap-metadata` (capability URI
275/// `urn:ietf:params:jmap:metadata`), which adds namespace-keyed
276/// `metadata` / `privateMetadata` properties to each listed data type,
277/// filterable through `metadata*` conditions on that type's own `/query`
278/// (draft-ietf-jmap-metadata-04 §2, §3.6). Helpers live in
279/// `jmap-metadata-types`, `jmap-metadata-server`, and
280/// `jmap-metadata-client`.
281///
282/// **Pre-IETF escape.** Vendors who cannot wait for the metadata draft can
283/// either escape to `serde_json::Value` or fork the per-crate
284/// `FilterCondition` / `ComparatorProperty` types. See
285/// `crate-jmap-calendars-types/PLAN.md` for the hybrid sloppy-value
286/// pattern.
287///
288/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
289#[non_exhaustive]
290#[derive(Debug, Clone, PartialEq, Eq, Hash)]
291pub enum ComparatorProperty {
292    /// Sort by `receivedAt`.
293    ReceivedAt,
294    /// Sort by message size in octets.
295    Size,
296    /// Sort by the text of the From header field.
297    From,
298    /// Sort by the text of the To header field.
299    To,
300    /// Sort by the decoded Subject.
301    Subject,
302    /// Sort by `sentAt`.
303    SentAt,
304    /// Sort by whether Emails in the Thread have the given `keyword`.
305    HasKeyword,
306    /// Sort by whether all Emails in the Thread have the given `keyword`.
307    AllInThreadHaveKeyword,
308    /// Sort by whether some Emails in the Thread have the given `keyword`.
309    SomeInThreadHaveKeyword,
310    /// A server-extension property name not listed above.
311    Other(String),
312}
313
314impl_string_enum!(ComparatorProperty, "an Email comparator property string",
315    "receivedAt"              => ReceivedAt,
316    "size"                    => Size,
317    "from"                    => From,
318    "to"                      => To,
319    "subject"                 => Subject,
320    "sentAt"                  => SentAt,
321    "hasKeyword"              => HasKeyword,
322    "allInThreadHaveKeyword"  => AllInThreadHaveKeyword,
323    "someInThreadHaveKeyword" => SomeInThreadHaveKeyword,
324);
325
326// ---------------------------------------------------------------------------
327// EmailComparator (RFC 8621 §4.4.2)
328// ---------------------------------------------------------------------------
329
330/// Sort comparator for Email/query (RFC 8621 §4.4.2).
331///
332/// When `property` is one of the keyword-based variants
333/// ([`ComparatorProperty::HasKeyword`], [`ComparatorProperty::AllInThreadHaveKeyword`],
334/// [`ComparatorProperty::SomeInThreadHaveKeyword`]), the `keyword` field
335/// **MUST** also be set.  `is_ascending` defaults to `true` per RFC 8620 §5.5.
336///
337/// # Excluded from extras preservation
338///
339/// This type is **out of scope** for the workspace extras-preservation
340/// policy: it carries no flatten-extras `extra` field, and its
341/// [`ComparatorProperty`] field is a closed control enum that backends
342/// must dispatch on. See [`ComparatorProperty`] and
343/// [`EmailFilterCondition`] for the rationale and for the two recommended
344/// paths (`draft-ietf-jmap-metadata`, bd JMAP-ua37; or the pre-IETF
345/// sloppy-value escape).
346///
347/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
348///
349/// # Construction
350///
351/// Use [`EmailComparator::new`] to construct from outside this crate.
352/// The struct is `#[non_exhaustive]`; struct literal syntax is not available
353/// to external callers.
354///
355/// ```rust
356/// use jmap_mail_types::{EmailComparator, ComparatorProperty};
357///
358/// let mut cmp = EmailComparator::new(ComparatorProperty::ReceivedAt);
359/// cmp.is_ascending = false;  // sort descending
360/// ```
361#[non_exhaustive]
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "camelCase")]
364pub struct EmailComparator {
365    /// The property to sort by.
366    pub property: ComparatorProperty,
367
368    /// If `true`, sort ascending; if `false`, sort descending.
369    /// Defaults to `true` per RFC 8620 §5.5.
370    #[serde(default = "bool_true", skip_serializing_if = "is_true")]
371    pub is_ascending: bool,
372
373    /// Collation algorithm (e.g. `"i;ascii-casemap"`).
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub collation: Option<String>,
376
377    /// Required when `property` is one of the keyword-based variants
378    /// (RFC 8621 §4.4.2).
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub keyword: Option<Keyword>,
381}
382
383impl EmailComparator {
384    /// Construct an [`EmailComparator`] for the given property.
385    ///
386    /// `is_ascending` defaults to `true` (RFC 8620 §5.5 default).
387    /// `collation` and `keyword` default to `None`.
388    ///
389    /// Set fields directly after construction:
390    ///
391    /// ```rust
392    /// use jmap_mail_types::{EmailComparator, ComparatorProperty};
393    /// use jmap_mail_types::Keyword;
394    ///
395    /// let mut cmp = EmailComparator::new(ComparatorProperty::HasKeyword);
396    /// cmp.keyword = Some(Keyword::from("$flagged"));
397    /// cmp.is_ascending = false;
398    /// ```
399    pub fn new(property: ComparatorProperty) -> Self {
400        Self {
401            property,
402            is_ascending: true,
403            collation: None,
404            keyword: None,
405        }
406    }
407}
408
409// ---------------------------------------------------------------------------
410// MailboxComparator (RFC 8621 §2.3) and EmailSubmissionComparator (§7.3)
411// ---------------------------------------------------------------------------
412
413/// The property to sort by in a [`MailboxComparator`].
414///
415/// RFC 8621 §2.3: "The following Mailbox properties MUST be supported for
416/// sorting: "sortOrder" "name"". Unknown names are kept in
417/// [`Other`](MailboxComparatorProperty::Other) so they round-trip; a server
418/// answers one it does not support with `unsupportedSort` (RFC 8620 §5.5).
419#[non_exhaustive]
420#[derive(Debug, Clone, PartialEq, Eq, Hash)]
421pub enum MailboxComparatorProperty {
422    /// Sort by `sortOrder`.
423    SortOrder,
424    /// Sort by `name`.
425    Name,
426    /// A property not listed above, kept so it round-trips; a server
427    /// answers it with `unsupportedSort` (RFC 8620 §5.5).
428    Other(String),
429}
430
431impl_string_enum!(MailboxComparatorProperty, "a Mailbox sort property string",
432    "sortOrder" => SortOrder,
433    "name"      => Name,
434);
435
436/// Sort comparator for `Mailbox/query` (RFC 8621 §2.3).
437///
438/// Out of scope for extras preservation for the same reason as
439/// [`EmailComparator`]: backends dispatch on `property`.
440///
441/// Build one with [`MailboxComparator::new`]; the struct is
442/// `#[non_exhaustive]`.
443#[non_exhaustive]
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(rename_all = "camelCase")]
446pub struct MailboxComparator {
447    /// The property to sort by.
448    pub property: MailboxComparatorProperty,
449
450    /// If `true`, sort ascending; if `false`, sort descending.
451    /// Defaults to `true` per RFC 8620 §5.5.
452    #[serde(default = "bool_true", skip_serializing_if = "is_true")]
453    pub is_ascending: bool,
454
455    /// Collation algorithm (e.g. `"i;unicode-casemap"`).
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub collation: Option<String>,
458}
459
460impl MailboxComparator {
461    /// An ascending comparator on `property` with no collation
462    /// (`isAscending` defaults to `true`, RFC 8620 §5.5).
463    pub fn new(property: MailboxComparatorProperty) -> Self {
464        Self {
465            property,
466            is_ascending: true,
467            collation: None,
468        }
469    }
470}
471
472/// The property to sort by in an [`EmailSubmissionComparator`].
473///
474/// RFC 8621 §7.3: "The following EmailSubmission properties MUST be
475/// supported for sorting: "emailId" "threadId" "sentAt"". Unknown names
476/// are kept in [`Other`](EmailSubmissionComparatorProperty::Other) so they
477/// round-trip; a server answers one it does not support with
478/// `unsupportedSort` (RFC 8620 §5.5).
479#[non_exhaustive]
480#[derive(Debug, Clone, PartialEq, Eq, Hash)]
481pub enum EmailSubmissionComparatorProperty {
482    /// Sort by `emailId`.
483    EmailId,
484    /// Sort by `threadId`.
485    ThreadId,
486    /// Sort by `sentAt`.
487    SentAt,
488    /// A property not listed above, kept so it round-trips; a server
489    /// answers it with `unsupportedSort` (RFC 8620 §5.5).
490    Other(String),
491}
492
493impl_string_enum!(EmailSubmissionComparatorProperty, "an EmailSubmission sort property string",
494    "emailId"  => EmailId,
495    "threadId" => ThreadId,
496    "sentAt"   => SentAt,
497);
498
499/// Sort comparator for `EmailSubmission/query` (RFC 8621 §7.3).
500///
501/// Out of scope for extras preservation for the same reason as
502/// [`EmailComparator`]: backends dispatch on `property`.
503///
504/// Build one with [`EmailSubmissionComparator::new`]; the struct is
505/// `#[non_exhaustive]`.
506#[non_exhaustive]
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
508#[serde(rename_all = "camelCase")]
509pub struct EmailSubmissionComparator {
510    /// The property to sort by.
511    pub property: EmailSubmissionComparatorProperty,
512
513    /// If `true`, sort ascending; if `false`, sort descending.
514    /// Defaults to `true` per RFC 8620 §5.5.
515    #[serde(default = "bool_true", skip_serializing_if = "is_true")]
516    pub is_ascending: bool,
517
518    /// Collation algorithm (e.g. `"i;ascii-casemap"`).
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub collation: Option<String>,
521}
522
523impl EmailSubmissionComparator {
524    /// An ascending comparator on `property` with no collation
525    /// (`isAscending` defaults to `true`, RFC 8620 §5.5).
526    pub fn new(property: EmailSubmissionComparatorProperty) -> Self {
527        Self {
528            property,
529            is_ascending: true,
530            collation: None,
531        }
532    }
533}
534
535/// The property to sort by in a [`SieveScriptComparator`].
536///
537/// RFC 9661 §2.5: "The following SieveScript properties MUST be supported
538/// for sorting: name, isActive". Unknown names are kept in
539/// [`Other`](SieveScriptComparatorProperty::Other) so they round-trip; a
540/// server answers one it does not support with `unsupportedSort` (RFC 8620
541/// §5.5).
542#[non_exhaustive]
543#[derive(Debug, Clone, PartialEq, Eq, Hash)]
544pub enum SieveScriptComparatorProperty {
545    /// Sort by `name`.
546    Name,
547    /// Sort by `isActive`.
548    IsActive,
549    /// A property not listed above, kept so it round-trips; a server
550    /// answers it with `unsupportedSort` (RFC 8620 §5.5).
551    Other(String),
552}
553
554impl_string_enum!(SieveScriptComparatorProperty, "a SieveScript sort property string",
555    "name"     => Name,
556    "isActive" => IsActive,
557);
558
559/// Sort comparator for `SieveScript/query` (RFC 9661 §2.5).
560///
561/// Out of scope for extras preservation for the same reason as
562/// [`EmailComparator`]: servers dispatch on `property`.
563///
564/// Build one with [`SieveScriptComparator::new`]; the struct is
565/// `#[non_exhaustive]`.
566#[non_exhaustive]
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568#[serde(rename_all = "camelCase")]
569pub struct SieveScriptComparator {
570    /// The property to sort by.
571    pub property: SieveScriptComparatorProperty,
572
573    /// If `true`, sort ascending; if `false`, sort descending.
574    /// Defaults to `true` per RFC 8620 §5.5.
575    #[serde(default = "bool_true", skip_serializing_if = "is_true")]
576    pub is_ascending: bool,
577
578    /// Collation algorithm (e.g. `"i;unicode-casemap"`).
579    #[serde(skip_serializing_if = "Option::is_none")]
580    pub collation: Option<String>,
581}
582
583impl SieveScriptComparator {
584    /// An ascending comparator on `property` with no collation
585    /// (`isAscending` defaults to `true`, RFC 8620 §5.5).
586    pub fn new(property: SieveScriptComparatorProperty) -> Self {
587        Self {
588            property,
589            is_ascending: true,
590            collation: None,
591        }
592    }
593}
594
595fn bool_true() -> bool {
596    true
597}
598
599fn is_true(b: &bool) -> bool {
600    *b
601}
602
603// ---------------------------------------------------------------------------
604// Deserializer helpers
605// ---------------------------------------------------------------------------
606
607/// Deserialize `header` and enforce the 1–2 element constraint from RFC 8621 §4.4.1.
608fn deserialize_header<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
609where
610    D: serde::Deserializer<'de>,
611{
612    let v: Option<Vec<String>> = Option::deserialize(deserializer)?;
613    if let Some(ref h) = v
614        && (h.is_empty() || h.len() > 2)
615    {
616        return Err(serde::de::Error::custom(format!(
617            "header must have 1 or 2 elements (RFC 8621 §4.4.1), got {}",
618            h.len()
619        )));
620    }
621    Ok(v)
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    /// Oracle: RFC 8621 §4.4 example — filter by Mailbox id.
629    /// Wire format from RFC 8621 §4.4 (line 3240 area of the RFC text).
630    #[test]
631    fn filter_condition_in_mailbox() {
632        let json = r#"{"inMailbox":"fb666a55"}"#;
633        let f: EmailFilter = serde_json::from_str(json).expect("must parse");
634        match &f {
635            Filter::Condition(c) => {
636                assert_eq!(
637                    c.in_mailbox.as_ref().map(|id| id.as_ref()),
638                    Some("fb666a55")
639                );
640            }
641            other => panic!("expected Condition, got {other:?}"),
642        }
643        let back = serde_json::to_string(&f).expect("serialize");
644        assert_eq!(back, json);
645    }
646
647    /// Oracle: RFC 8620 §5.5 example — OR of two keyword conditions applied to Email.
648    /// Adapted from the Todo/query example in RFC 8620 §5.5.
649    #[test]
650    fn filter_operator_or_two_keywords() {
651        let json =
652            r#"{"operator":"OR","conditions":[{"hasKeyword":"music"},{"hasKeyword":"video"}]}"#;
653        let f: EmailFilter = serde_json::from_str(json).expect("must parse");
654        match &f {
655            Filter::Operator(op) => {
656                assert_eq!(op.operator, Operator::Or);
657                assert_eq!(op.conditions.len(), 2);
658                match &op.conditions[0] {
659                    Filter::Condition(c) => {
660                        assert_eq!(c.has_keyword.as_deref(), Some("music"))
661                    }
662                    other => panic!("expected Condition, got {other:?}"),
663                }
664                match &op.conditions[1] {
665                    Filter::Condition(c) => {
666                        assert_eq!(c.has_keyword.as_deref(), Some("video"))
667                    }
668                    other => panic!("expected Condition, got {other:?}"),
669                }
670            }
671            other => panic!("expected Operator, got {other:?}"),
672        }
673        let back = serde_json::to_string(&f).expect("serialize");
674        assert_eq!(back, json);
675    }
676
677    /// Oracle: nested AND(OR(...)) structure roundtrips.
678    #[test]
679    fn nested_and_or_roundtrip() {
680        let filter = EmailFilter::Operator(FilterOperator::new(
681            Operator::And,
682            vec![
683                EmailFilter::Condition(EmailFilterCondition {
684                    in_mailbox: Some(Id::from("inbox-id")),
685                    ..Default::default()
686                }),
687                EmailFilter::Operator(FilterOperator::new(
688                    Operator::Or,
689                    vec![
690                        EmailFilter::Condition(EmailFilterCondition {
691                            has_keyword: Some(Keyword::from("$flagged")),
692                            ..Default::default()
693                        }),
694                        EmailFilter::Condition(EmailFilterCondition {
695                            has_keyword: Some(Keyword::from("$answered")),
696                            ..Default::default()
697                        }),
698                    ],
699                )),
700            ],
701        ));
702        let json = serde_json::to_string(&filter).expect("serialize");
703        let back: EmailFilter = serde_json::from_str(&json).expect("deserialize");
704        assert_eq!(filter, back);
705    }
706
707    /// Oracle: empty EmailFilterCondition serializes to `{}` and omits all fields.
708    #[test]
709    fn empty_condition_serializes_to_empty_object() {
710        let c = EmailFilterCondition::default();
711        let json = serde_json::to_string(&c).expect("serialize");
712        assert_eq!(json, "{}");
713    }
714
715    /// Oracle: all scalar fields roundtrip correctly.
716    #[test]
717    fn all_scalar_fields_roundtrip() {
718        let c = EmailFilterCondition {
719            in_mailbox: Some(Id::from("mb1")),
720            min_size: Some(1024),
721            max_size: Some(65536),
722            all_in_thread_have_keyword: Some(Keyword::from("$seen")),
723            some_in_thread_have_keyword: Some(Keyword::from("$flagged")),
724            none_in_thread_have_keyword: Some(Keyword::from("$draft")),
725            has_keyword: Some(Keyword::from("$answered")),
726            not_keyword: Some(Keyword::from("$junk")),
727            has_attachment: Some(true),
728            text: Some("hello".to_owned()),
729            from: Some("alice@example.com".to_owned()),
730            to: Some("bob@example.com".to_owned()),
731            cc: Some("carol@example.com".to_owned()),
732            bcc: Some("dave@example.com".to_owned()),
733            subject: Some("Meeting".to_owned()),
734            body: Some("agenda".to_owned()),
735            ..Default::default()
736        };
737        let json = serde_json::to_string(&c).expect("serialize");
738        let back: EmailFilterCondition = serde_json::from_str(&json).expect("deserialize");
739        assert_eq!(c, back);
740    }
741
742    /// Oracle: `header` with one element (field name only) is accepted.
743    #[test]
744    fn header_one_element_accepted() {
745        let json = r#"{"header":["X-Spam-Status"]}"#;
746        let c: EmailFilterCondition = serde_json::from_str(json).expect("must parse");
747        let h = c.header.as_ref().expect("header must be present");
748        assert_eq!(h.len(), 1);
749        assert_eq!(h[0], "X-Spam-Status");
750    }
751
752    /// Oracle: `header` with two elements (field name + value) is accepted.
753    #[test]
754    fn header_two_elements_accepted() {
755        let json = r#"{"header":["X-Spam-Status","Yes"]}"#;
756        let c: EmailFilterCondition = serde_json::from_str(json).expect("must parse");
757        let h = c.header.as_ref().expect("header must be present");
758        assert_eq!(h.len(), 2);
759        assert_eq!(h[0], "X-Spam-Status");
760        assert_eq!(h[1], "Yes");
761    }
762
763    /// Oracle: RFC 8621 §4.4.1 — `header` with zero elements is a protocol error.
764    #[test]
765    fn header_zero_elements_rejected() {
766        let json = r#"{"header":[]}"#;
767        let err = serde_json::from_str::<EmailFilterCondition>(json)
768            .expect_err("0-element header must fail");
769        let msg = err.to_string();
770        assert!(
771            msg.contains("header must have 1 or 2 elements"),
772            "unexpected error: {msg}"
773        );
774    }
775
776    /// Oracle: RFC 8621 §4.4.1 — `header` with three elements is a protocol error.
777    #[test]
778    fn header_three_elements_rejected() {
779        let json = r#"{"header":["X-Foo","bar","extra"]}"#;
780        let err = serde_json::from_str::<EmailFilterCondition>(json)
781            .expect_err("3-element header must fail");
782        let msg = err.to_string();
783        assert!(
784            msg.contains("header must have 1 or 2 elements"),
785            "unexpected error: {msg}"
786        );
787    }
788
789    /// Oracle: `before` and `after` fields use UTCDate wire format (RFC 3339 UTC).
790    #[test]
791    fn date_fields_roundtrip() {
792        let json = r#"{"before":"2024-01-15T12:00:00Z","after":"2024-01-01T00:00:00Z"}"#;
793        let c: EmailFilterCondition = serde_json::from_str(json).expect("must parse");
794        let back = serde_json::to_string(&c).expect("serialize");
795        assert_eq!(back, json);
796    }
797
798    /// Oracle: `inMailboxOtherThan` is a list of Ids.
799    #[test]
800    fn in_mailbox_other_than_roundtrip() {
801        let json = r#"{"inMailboxOtherThan":["trash-id","spam-id"]}"#;
802        let c: EmailFilterCondition = serde_json::from_str(json).expect("must parse");
803        let ids: Vec<&str> = c
804            .in_mailbox_other_than
805            .as_ref()
806            .unwrap()
807            .iter()
808            .map(|id| id.as_ref())
809            .collect();
810        assert_eq!(ids, ["trash-id", "spam-id"]);
811        let back = serde_json::to_string(&c).expect("serialize");
812        assert_eq!(back, json);
813    }
814
815    // ---------------------------------------------------------------------------
816    // MailboxComparator / EmailSubmissionComparator tests
817    // ---------------------------------------------------------------------------
818
819    /// Oracle: the RFC 8621 §2.3 sort-property names "sortOrder" and
820    /// "name", and the RFC 8620 §5.5 `isAscending` default of true.
821    #[test]
822    fn mailbox_comparator_rfc8621_properties() {
823        let c: MailboxComparator =
824            serde_json::from_str(r#"{"property":"sortOrder"}"#).expect("must parse");
825        assert_eq!(c.property, MailboxComparatorProperty::SortOrder);
826        assert!(c.is_ascending);
827        let c: MailboxComparator =
828            serde_json::from_str(r#"{"property":"name","isAscending":false}"#).expect("must parse");
829        assert_eq!(c.property, MailboxComparatorProperty::Name);
830        assert!(!c.is_ascending);
831        assert_eq!(
832            serde_json::to_value(&c).unwrap(),
833            serde_json::json!({"property":"name","isAscending":false})
834        );
835    }
836
837    /// Oracle: hand-written JSON; an unknown property survives as `Other`
838    /// and re-serializes byte-for-byte (AGENTS.md comparator rule).
839    #[test]
840    fn mailbox_comparator_unknown_property_round_trips() {
841        let json = serde_json::json!({"property":"totalEmails"});
842        let c: MailboxComparator = serde_json::from_value(json.clone()).expect("must parse");
843        assert_eq!(
844            c.property,
845            MailboxComparatorProperty::Other("totalEmails".to_owned())
846        );
847        assert_eq!(serde_json::to_value(&c).unwrap(), json);
848    }
849
850    /// Oracle: the RFC 8621 §7.3 sort-property names "emailId",
851    /// "threadId" and "sentAt".
852    #[test]
853    fn email_submission_comparator_rfc8621_properties() {
854        for (wire, want) in [
855            ("emailId", EmailSubmissionComparatorProperty::EmailId),
856            ("threadId", EmailSubmissionComparatorProperty::ThreadId),
857            ("sentAt", EmailSubmissionComparatorProperty::SentAt),
858        ] {
859            let json = serde_json::json!({ "property": wire });
860            let c: EmailSubmissionComparator =
861                serde_json::from_value(json.clone()).expect("must parse");
862            assert_eq!(c.property, want);
863            assert!(c.is_ascending);
864            assert_eq!(serde_json::to_value(&c).unwrap(), json);
865        }
866        let c: EmailSubmissionComparator =
867            serde_json::from_str(r#"{"property":"undoStatus"}"#).expect("must parse");
868        assert_eq!(
869            c.property,
870            EmailSubmissionComparatorProperty::Other("undoStatus".to_owned())
871        );
872    }
873
874    // ---------------------------------------------------------------------------
875    // EmailComparator tests
876    // ---------------------------------------------------------------------------
877
878    /// Oracle: RFC 8621 §4.4.2 example — three-comparator sort array.
879    /// Wire format taken verbatim from the RFC 8621 §4.4.2 example.
880    #[test]
881    fn comparator_example_from_rfc() {
882        // Three comparators from RFC 8621 §4.4.2.
883        // First: keyword sort (isAscending=false, keyword present).
884        let json0 =
885            r#"{"property":"someInThreadHaveKeyword","keyword":"$flagged","isAscending":false}"#;
886        let c0: EmailComparator = serde_json::from_str(json0).expect("must parse");
887        assert_eq!(c0.property, ComparatorProperty::SomeInThreadHaveKeyword);
888        assert_eq!(c0.keyword.as_deref(), Some("$flagged"));
889        assert!(!c0.is_ascending);
890
891        // Second: subject sort with collation, isAscending defaults to true (omitted).
892        let json1 = r#"{"property":"subject","collation":"i;ascii-casemap"}"#;
893        let c1: EmailComparator = serde_json::from_str(json1).expect("must parse");
894        assert_eq!(c1.property, ComparatorProperty::Subject);
895        assert_eq!(c1.collation.as_deref(), Some("i;ascii-casemap"));
896        assert!(c1.is_ascending);
897        // isAscending is true (default) and must be omitted on serialization.
898        let back1 = serde_json::to_string(&c1).expect("serialize");
899        assert_eq!(back1, json1);
900
901        // Third: receivedAt descending.
902        let json2 = r#"{"property":"receivedAt","isAscending":false}"#;
903        let c2: EmailComparator = serde_json::from_str(json2).expect("must parse");
904        assert_eq!(c2.property, ComparatorProperty::ReceivedAt);
905        assert!(!c2.is_ascending);
906        let back2 = serde_json::to_string(&c2).expect("serialize");
907        assert_eq!(back2, json2);
908    }
909
910    /// Oracle: isAscending defaults to true when absent; omitted from serialization
911    /// when true (RFC 8620 §5.5 default).
912    #[test]
913    fn comparator_is_ascending_default_and_skip() {
914        let json = r#"{"property":"receivedAt"}"#;
915        let c: EmailComparator = serde_json::from_str(json).expect("must parse");
916        assert!(c.is_ascending, "default must be true");
917        let back = serde_json::to_string(&c).expect("serialize");
918        assert_eq!(back, json, "isAscending:true must be omitted");
919    }
920
921    // ---------------------------------------------------------------------------
922    // Filter ambiguity tests (JMAP-amk.2)
923    // ---------------------------------------------------------------------------
924
925    /// Oracle: RFC 8620 §5.5 — "unsupportedFilter: The "filter" is
926    /// syntactically valid, but the server cannot process it"; RFC 8621
927    /// §4.4.1 lists the Email FilterCondition properties, and
928    /// `unknownField` / `in_mailbox` are not among them. An unknown or
929    /// snake_case-misspelt property, at the top level or nested under an
930    /// operator, must fail the decode rather than become an all-None
931    /// condition that matches every Email.
932    #[test]
933    fn filter_unknown_fields_are_rejected() {
934        for raw in [
935            r#"{"unknownField":"value"}"#,
936            r#"{"in_mailbox":"inbox"}"#,
937            r#"{"inMailbox":"inbox","unknownField":1}"#,
938            r#"{"operator":"AND","conditions":[{"operator":"NOT","conditions":[{"in_mailbox":"x"}]}]}"#,
939            r#"{"operator":"AND","conditions":[],"inMailbox":"x"}"#,
940        ] {
941            assert!(
942                serde_json::from_str::<EmailFilter>(raw).is_err(),
943                "{raw} must be rejected"
944            );
945        }
946        // Negative control: every RFC 8621 §4.4.1 property decodes.
947        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"]}"#;
948        let f: EmailFilter = serde_json::from_str(all_known).expect("all §4.4.1 properties decode");
949        assert!(matches!(f, Filter::Condition(_)));
950    }
951
952    /// Oracle: an empty JSON object `{}` produces an empty condition (all None),
953    /// which matches every Email per RFC 8621 §4.4.1.
954    #[test]
955    fn filter_empty_object_becomes_empty_condition() {
956        let json = "{}";
957        let f: EmailFilter = serde_json::from_str(json).expect("must parse");
958        assert!(
959            matches!(&f, Filter::Condition(c) if c == &EmailFilterCondition::default()),
960            "empty object must yield all-None Condition"
961        );
962    }
963
964    /// Oracle: EmailComparator::new produces expected defaults and correct JSON.
965    /// RFC 8620 §5.5 — isAscending defaults to true and is omitted when true.
966    #[test]
967    fn comparator_new_constructor() {
968        let cmp = EmailComparator::new(ComparatorProperty::ReceivedAt);
969        assert_eq!(cmp.property, ComparatorProperty::ReceivedAt);
970        assert!(cmp.is_ascending);
971        assert!(cmp.collation.is_none());
972        assert!(cmp.keyword.is_none());
973        // isAscending:true is the default and must be omitted on the wire.
974        let json = serde_json::to_string(&cmp).expect("serialize");
975        assert_eq!(json, r#"{"property":"receivedAt"}"#);
976    }
977
978    /// Oracle: EmailComparator::new with field mutation produces correct JSON.
979    /// RFC 8621 §4.4.2 — keyword-based comparator must include keyword field.
980    #[test]
981    fn comparator_new_with_mutation() {
982        let mut cmp = EmailComparator::new(ComparatorProperty::HasKeyword);
983        cmp.keyword = Some(Keyword::from("$flagged"));
984        cmp.is_ascending = false;
985        let json = serde_json::to_string(&cmp).expect("serialize");
986        assert_eq!(
987            json,
988            r#"{"property":"hasKeyword","isAscending":false,"keyword":"$flagged"}"#
989        );
990    }
991
992    /// Oracle: the `filter` of the draft-ietf-jmap-metadata-02 §6.7 example
993    /// request, copied from the draft (-04 §3.6 still defines all three
994    /// leaf conditions). The metadata leaves decode inside the `AND` tree
995    /// and serialize back unchanged.
996    #[test]
997    fn metadata_conditions_survive_operator_tree() {
998        let wire = serde_json::json!({
999            "operator": "AND",
1000            "conditions": [
1001                { "inMailbox": "MB-inbox" },
1002                { "privateMetadataExists": "acme.example.com/memo" },
1003                {
1004                    "privateMetadataTextContains": {
1005                        "path":  "acme.example.com/memo",
1006                        "value": "follow up"
1007                    }
1008                }
1009            ]
1010        });
1011        let filter: EmailFilter = serde_json::from_value(wire.clone()).expect("decode");
1012        let Filter::Operator(op) = &filter else {
1013            panic!("expected an operator, got {filter:?}");
1014        };
1015        let leaves: Vec<&EmailFilterCondition> = op
1016            .conditions
1017            .iter()
1018            .map(|f| match f {
1019                Filter::Condition(c) => c,
1020                other => panic!("expected a condition, got {other:?}"),
1021            })
1022            .collect();
1023        assert!(!leaves[0].has_metadata_condition());
1024        assert_eq!(
1025            leaves[1].private_metadata_exists.as_deref(),
1026            Some("acme.example.com/memo")
1027        );
1028        assert!(leaves[1].has_metadata_condition());
1029        assert_eq!(
1030            leaves[2].private_metadata_text_contains,
1031            Some(MetadataTextMatch::new("acme.example.com/memo", "follow up"))
1032        );
1033        assert_eq!(serde_json::to_value(&filter).expect("encode"), wire);
1034    }
1035
1036    /// Oracle: the six wire names of -04 §3.6 ("metadataExists",
1037    /// "privateMetadataExists", "metadataTextContains",
1038    /// "privateMetadataTextContains", "metadataTextEquals",
1039    /// "privateMetadataTextEquals") each decode; a misspelt one is still
1040    /// rejected by `deny_unknown_fields`.
1041    #[test]
1042    fn metadata_condition_names_decode_and_typos_reject() {
1043        let m = serde_json::json!({"path": "ns/k", "value": "v"});
1044        for (name, value) in [
1045            ("metadataExists", serde_json::json!("ns")),
1046            ("privateMetadataExists", serde_json::json!("ns")),
1047            ("metadataTextContains", m.clone()),
1048            ("privateMetadataTextContains", m.clone()),
1049            ("metadataTextEquals", m.clone()),
1050            ("privateMetadataTextEquals", m.clone()),
1051        ] {
1052            let c: EmailFilterCondition =
1053                serde_json::from_value(serde_json::json!({ name: value })).expect(name);
1054            assert!(c.has_metadata_condition(), "{name}");
1055        }
1056        assert!(
1057            serde_json::from_value::<EmailFilterCondition>(serde_json::json!({
1058                "metadataExist": "ns"
1059            }))
1060            .is_err()
1061        );
1062    }
1063}