use std::fmt;
use super::validated::{is_valid_sequence_set, validate_atom_bytes};
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SearchCriteria {
buf: String,
}
impl SearchCriteria {
#[must_use]
pub fn new() -> Self {
Self { buf: String::new() }
}
pub fn as_str(&self) -> &str {
&self.buf
}
fn sep(&mut self) {
if !self.buf.is_empty() {
self.buf.push(' ');
}
}
fn push_keyword(mut self, keyword: &str) -> Self {
self.sep();
self.buf.push_str(keyword);
self
}
fn push_date(mut self, key: &str, date: &str) -> Result<Self, crate::Error> {
validate_imap_date(date)?;
self.sep();
self.buf.push_str(key);
self.buf.push(' ');
self.buf.push_str(date);
Ok(self)
}
fn push_string(mut self, key: &str, value: &str) -> Result<Self, crate::Error> {
self.sep();
self.buf.push_str(key);
self.buf.push(' ');
quote_imap_string(&mut self.buf, value)?;
Ok(self)
}
fn push_number(mut self, key: &str, n: u64) -> Self {
self.sep();
self.buf.push_str(key);
self.buf.push(' ');
self.buf.push_str(&n.to_string());
self
}
#[must_use]
pub fn all(self) -> Self {
self.push_keyword("ALL")
}
#[must_use]
pub fn answered(self) -> Self {
self.push_keyword("ANSWERED")
}
#[must_use]
pub fn deleted(self) -> Self {
self.push_keyword("DELETED")
}
#[must_use]
pub fn draft(self) -> Self {
self.push_keyword("DRAFT")
}
#[must_use]
pub fn flagged(self) -> Self {
self.push_keyword("FLAGGED")
}
#[must_use]
pub fn seen(self) -> Self {
self.push_keyword("SEEN")
}
#[must_use]
pub fn recent(self) -> Self {
self.push_keyword("RECENT")
}
#[must_use]
pub fn new_messages(self) -> Self {
self.push_keyword("NEW")
}
#[must_use]
pub fn old(self) -> Self {
self.push_keyword("OLD")
}
#[must_use]
pub fn unanswered(self) -> Self {
self.push_keyword("UNANSWERED")
}
#[must_use]
pub fn undeleted(self) -> Self {
self.push_keyword("UNDELETED")
}
#[must_use]
pub fn undraft(self) -> Self {
self.push_keyword("UNDRAFT")
}
#[must_use]
pub fn unflagged(self) -> Self {
self.push_keyword("UNFLAGGED")
}
#[must_use]
pub fn unseen(self) -> Self {
self.push_keyword("UNSEEN")
}
pub fn keyword(mut self, flag: &str) -> Result<Self, crate::Error> {
validate_atom_bytes(flag.as_bytes(), "KEYWORD flag")?;
self.sep();
self.buf.push_str("KEYWORD ");
self.buf.push_str(flag);
Ok(self)
}
pub fn unkeyword(mut self, flag: &str) -> Result<Self, crate::Error> {
validate_atom_bytes(flag.as_bytes(), "UNKEYWORD flag")?;
self.sep();
self.buf.push_str("UNKEYWORD ");
self.buf.push_str(flag);
Ok(self)
}
pub fn bcc(self, s: &str) -> Result<Self, crate::Error> {
self.push_string("BCC", s)
}
pub fn cc(self, s: &str) -> Result<Self, crate::Error> {
self.push_string("CC", s)
}
pub fn from(self, s: &str) -> Result<Self, crate::Error> {
self.push_string("FROM", s)
}
pub fn to(self, s: &str) -> Result<Self, crate::Error> {
self.push_string("TO", s)
}
pub fn subject(self, s: &str) -> Result<Self, crate::Error> {
self.push_string("SUBJECT", s)
}
pub fn header(mut self, name: &str, value: &str) -> Result<Self, crate::Error> {
self.sep();
self.buf.push_str("HEADER ");
self.buf.push_str(name);
self.buf.push(' ');
quote_imap_string(&mut self.buf, value)?;
Ok(self)
}
pub fn body(self, s: &str) -> Result<Self, crate::Error> {
self.push_string("BODY", s)
}
pub fn text(self, s: &str) -> Result<Self, crate::Error> {
self.push_string("TEXT", s)
}
pub fn before(self, date: &str) -> Result<Self, crate::Error> {
self.push_date("BEFORE", date)
}
pub fn on(self, date: &str) -> Result<Self, crate::Error> {
self.push_date("ON", date)
}
pub fn since(self, date: &str) -> Result<Self, crate::Error> {
self.push_date("SINCE", date)
}
pub fn sent_before(self, date: &str) -> Result<Self, crate::Error> {
self.push_date("SENTBEFORE", date)
}
pub fn sent_on(self, date: &str) -> Result<Self, crate::Error> {
self.push_date("SENTON", date)
}
pub fn sent_since(self, date: &str) -> Result<Self, crate::Error> {
self.push_date("SENTSINCE", date)
}
#[must_use]
pub fn larger(self, n: u64) -> Self {
self.push_number("LARGER", n)
}
#[must_use]
pub fn smaller(self, n: u64) -> Self {
self.push_number("SMALLER", n)
}
pub fn uid(mut self, set: &str) -> Result<Self, crate::Error> {
if !is_valid_sequence_set(set) {
return Err(crate::Error::Protocol(format!(
"invalid sequence-set for UID search per RFC 3501 Section 9: {set:?}"
)));
}
self.sep();
self.buf.push_str("UID ");
self.buf.push_str(set);
Ok(self)
}
pub fn sequence(mut self, set: &str) -> Result<Self, crate::Error> {
if !is_valid_sequence_set(set) {
return Err(crate::Error::Protocol(format!(
"invalid sequence-set per RFC 3501 Section 9: {set:?}"
)));
}
self.sep();
self.buf.push_str(set);
Ok(self)
}
#[must_use]
pub fn mod_seq(self, value: u64) -> Self {
self.push_number("MODSEQ", value)
}
#[must_use]
pub fn not(mut self, criteria: &Self) -> Self {
self.sep();
self.buf.push_str("NOT ");
push_parenthesized_if_compound(&mut self.buf, criteria.as_str());
self
}
#[must_use]
pub fn or(mut self, a: &Self, b: &Self) -> Self {
self.sep();
self.buf.push_str("OR ");
push_parenthesized_if_compound(&mut self.buf, a.as_str());
self.buf.push(' ');
push_parenthesized_if_compound(&mut self.buf, b.as_str());
self
}
}
impl Default for SearchCriteria {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for SearchCriteria {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.buf)
}
}
impl AsRef<str> for SearchCriteria {
fn as_ref(&self) -> &str {
&self.buf
}
}
fn quote_imap_string(buf: &mut String, value: &str) -> Result<(), crate::Error> {
for &b in value.as_bytes() {
if b == 0 {
return Err(crate::Error::Protocol(
"quoted string must not contain NUL — NUL is not a valid CHAR \
(RFC 3501 Section 9: CHAR = <any 7-bit US-ASCII except NUL>)"
.into(),
));
}
if b == b'\r' {
return Err(crate::Error::Protocol(
"quoted string must not contain CR — CR is not a TEXT-CHAR \
(RFC 3501 Section 9: TEXT-CHAR = <any CHAR except CR and LF>)"
.into(),
));
}
if b == b'\n' {
return Err(crate::Error::Protocol(
"quoted string must not contain LF — LF is not a TEXT-CHAR \
(RFC 3501 Section 9: TEXT-CHAR = <any CHAR except CR and LF>)"
.into(),
));
}
}
buf.push('"');
for ch in value.chars() {
if ch == '\\' || ch == '"' {
buf.push('\\');
}
buf.push(ch);
}
buf.push('"');
Ok(())
}
fn validate_imap_date(date: &str) -> Result<(), crate::Error> {
const VALID_MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let parts: Vec<&str> = date.splitn(3, '-').collect();
if parts.len() != 3 {
return Err(crate::Error::Protocol(format!(
"invalid IMAP date {date:?} — must be date-day \"-\" date-month \"-\" date-year \
(RFC 3501 Section 9)"
)));
}
let (day, month, year) = (parts[0], parts[1], parts[2]);
if day.is_empty() || day.len() > 2 || !day.bytes().all(|b| b.is_ascii_digit()) {
return Err(crate::Error::Protocol(format!(
"invalid IMAP date day {day:?} — must be 1 or 2 digits \
(RFC 3501 Section 9: date-day = 1*2DIGIT)"
)));
}
if !VALID_MONTHS.contains(&month) {
return Err(crate::Error::Protocol(format!(
"invalid IMAP date month {month:?} — must be one of Jan, Feb, Mar, Apr, \
May, Jun, Jul, Aug, Sep, Oct, Nov, Dec \
(RFC 3501 Section 9: date-month)"
)));
}
if year.len() != 4 || !year.bytes().all(|b| b.is_ascii_digit()) {
return Err(crate::Error::Protocol(format!(
"invalid IMAP date year {year:?} — must be exactly 4 digits \
(RFC 3501 Section 9: date-year = 4DIGIT)"
)));
}
Ok(())
}
fn push_parenthesized_if_compound(buf: &mut String, criteria: &str) {
if is_compound(criteria) {
buf.push('(');
buf.push_str(criteria);
buf.push(')');
} else {
buf.push_str(criteria);
}
}
fn is_compound(criteria: &str) -> bool {
let tokens = count_top_level_tokens(criteria);
let first_token = top_level_first_token(criteria);
let max_tokens_for_single_key = match first_token.to_uppercase().as_str() {
"BCC" | "CC" | "FROM" | "TO" | "SUBJECT" | "BODY" | "TEXT" | "BEFORE" | "ON" | "SINCE"
| "SENTBEFORE" | "SENTON" | "SENTSINCE" | "LARGER" | "SMALLER" | "UID" | "KEYWORD"
| "UNKEYWORD" | "NOT" | "MODSEQ" => 2,
"HEADER" | "OR" => 3,
_ => 1,
};
tokens > max_tokens_for_single_key
}
fn count_top_level_tokens(s: &str) -> usize {
let mut count = 0;
let mut in_quote = false;
let mut in_token = false;
let mut paren_depth: u32 = 0;
let mut prev_backslash = false;
for ch in s.chars() {
if in_quote {
if prev_backslash {
prev_backslash = false;
continue;
}
if ch == '\\' {
prev_backslash = true;
continue;
}
if ch == '"' {
in_quote = false;
}
continue;
}
if ch == '"' {
if !in_token {
count += 1;
in_token = true;
}
in_quote = true;
continue;
}
if ch == '(' {
if paren_depth == 0 && !in_token {
count += 1;
in_token = true;
}
paren_depth = paren_depth.saturating_add(1);
continue;
}
if ch == ')' {
paren_depth = paren_depth.saturating_sub(1);
if paren_depth == 0 {
in_token = false;
}
continue;
}
if paren_depth > 0 {
continue;
}
if ch == ' ' {
in_token = false;
} else if !in_token {
count += 1;
in_token = true;
}
}
count
}
fn top_level_first_token(s: &str) -> &str {
let trimmed = s.trim_start();
match trimmed.find(' ') {
Some(pos) => &trimmed[..pos],
None => trimmed,
}
}
#[cfg(test)]
#[path = "search_tests.rs"]
mod tests;