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}