Real IMAP SEARCH: parse the query and match per message

cmd_search only ever special-cased UNSEEN — every other query (SUBJECT,
FROM, SINCE, …) fell through to "return all message ids", so a search in
Thunderbird silently matched the entire mailbox. Now that the full mailbox
is listed, that made search actively misleading.

New imap/search.rs parses the RFC 3501 SEARCH grammar into a SearchKey
tree (AND/OR/NOT, parens, CHARSET prefix, quoted strings, sequence/UID
sets) and matches each message via a lightweight MsgView the session
projects from its cached mail. Coverage is metadata-first: SUBJECT, FROM,
TO, CC, BCC, HEADER, flags, dates (BEFORE/ON/SINCE + SENT*), LARGER/
SMALLER, UID and sequence sets. Flag predicates resolve consistently with
what we report over FETCH (only \Seen and \Deleted exist). BODY/TEXT match
the body only when it's already decoded — whole-mailbox full-text body
search is the next increment, backed by an on-disk index.

Unknown criteria degrade to a non-restrictive match so search never hides
a message.

Validated live on a 19,322-message INBOX: SEEN+UNSEEN partition the
mailbox exactly, NOT SEEN == UNSEEN, AND/OR compose, and a nonexistent
subject now returns 0 hits instead of everything. 223 unit tests.
This commit is contained in:
Anthony
2026-05-29 18:58:21 +02:00
parent 472eb7880e
commit 24c3a1d908
3 changed files with 875 additions and 17 deletions
+1
View File
@@ -1,3 +1,4 @@
mod search;
mod session;
mod utf7;
+755
View File
@@ -0,0 +1,755 @@
//! IMAP `SEARCH` parsing + matching (RFC 3501 §6.4.4).
//!
//! This module is deliberately self-contained and free of any `Mail`/session
//! types so it can be unit-tested in isolation: the session layer extracts the
//! handful of comparable fields into a [`MsgView`] and asks [`matches`] whether
//! a given message satisfies a parsed [`SearchKey`].
//!
//! Coverage is metadata-first: subject / from / to / cc, flags, dates, sizes,
//! sequence + UID sets, and boolean composition (`AND` / `OR` / `NOT`). `BODY`
//! and `TEXT` match against whatever body text the session already has decoded
//! — full-text body search over the whole mailbox is a later increment backed
//! by an on-disk index.
//!
//! Robustness rule: an unrecognised criterion degrades to a non-restrictive
//! match (it never *hides* messages). Over-inclusion is the safe failure for
//! search; silently dropping a matching mail is not.
/// One element of an IMAP sequence/UID set, e.g. `1`, `3:9`, or `5:*`.
#[derive(Debug, Clone, PartialEq, Eq)]
struct SeqRange {
start: u32,
/// `None` represents `*` (the largest value present). Since any concrete
/// value is `<=` the max, an open-ended range is just `value >= start`.
end: Option<u32>,
}
/// A parsed sequence or UID set (comma-separated [`SeqRange`]s).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SeqSet {
ranges: Vec<SeqRange>,
}
impl SeqSet {
fn contains(&self, value: u32) -> bool {
self.ranges.iter().any(|r| match r.end {
None => value >= r.start,
Some(end) => {
let (lo, hi) = if r.start <= end {
(r.start, end)
} else {
(end, r.start)
};
value >= lo && value <= hi
}
})
}
}
/// A parsed `SEARCH` query. `And` with an empty vec means "match everything"
/// (the bare `SEARCH ALL` case), which keeps composition simple.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SearchKey {
All,
And(Vec<SearchKey>),
Or(Box<SearchKey>, Box<SearchKey>),
Not(Box<SearchKey>),
// Flag predicates. We only ever expose `\Seen` and `\Deleted` over FETCH,
// so the others resolve to a fixed answer to stay consistent with what a
// client sees in FLAGS (e.g. nothing is ever `\Answered`).
Seen,
Unseen,
Answered,
Unanswered,
Flagged,
Unflagged,
Draft,
Undraft,
Deleted,
Undeleted,
Recent,
New,
Old,
Keyword,
Unkeyword,
// Header / string predicates (case-insensitive substring).
Subject(String),
From(String),
To(String),
Cc(String),
Bcc(String),
Body(String),
Text(String),
Header(String, String),
// Date predicates, stored as a day number (days since the Unix epoch).
// `Before`/`On`/`Since` test the internal (received) date.
Before(i64),
On(i64),
Since(i64),
SentBefore(i64),
SentOn(i64),
SentSince(i64),
// Size predicates (against RFC822.SIZE).
Larger(u64),
Smaller(u64),
Uid(SeqSet),
Sequence(SeqSet),
}
/// The slice of a message the matcher needs. Derived fields (From / To / Cc /
/// Bcc / headers) are owned because the session builds them per search; the
/// cheap fields (subject, body) borrow directly from the cached message.
pub struct MsgView<'a> {
pub seq: u32,
pub uid: u32,
pub subject: &'a str,
/// Formatted `From` (name + address), for `FROM` substring matching.
pub from: String,
pub to: String,
pub cc: String,
pub bcc: String,
/// Raw header block (everything before the blank line), for `HEADER`/`TEXT`.
pub headers: String,
/// Received date in epoch millis (also our INTERNALDATE).
pub date_ms: u64,
/// Sent date in epoch millis; falls back to `date_ms` when unknown.
pub sent_ms: u64,
pub unread: bool,
pub deleted: bool,
pub size: u64,
/// Decoded body text, when the session already has it. `None` means the
/// body isn't loaded; `BODY`/`TEXT` simply won't match such a message yet.
pub body: Option<&'a str>,
}
fn contains_ci(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
haystack.to_lowercase().contains(&needle.to_lowercase())
}
/// Does any header line whose field name equals `name` contain `value`?
/// With an empty `value`, matches when the header is merely present.
fn header_contains(headers: &str, name: &str, value: &str) -> bool {
let name_lc = name.to_lowercase();
for line in headers.split("\r\n") {
if let Some((field, val)) = line.split_once(':') {
if field.trim().to_lowercase() == name_lc {
if value.is_empty() || contains_ci(val, value) {
return true;
}
}
}
}
false
}
fn day_number(ms: u64) -> i64 {
(ms / 86_400_000) as i64
}
/// Evaluate a parsed query against one message.
pub fn matches(key: &SearchKey, m: &MsgView) -> bool {
match key {
SearchKey::All => true,
SearchKey::And(keys) => keys.iter().all(|k| matches(k, m)),
SearchKey::Or(a, b) => matches(a, m) || matches(b, m),
SearchKey::Not(k) => !matches(k, m),
SearchKey::Seen => !m.unread,
SearchKey::Unseen => m.unread,
SearchKey::Answered => false,
SearchKey::Unanswered => true,
SearchKey::Flagged => false,
SearchKey::Unflagged => true,
SearchKey::Draft => false,
SearchKey::Undraft => true,
SearchKey::Deleted => m.deleted,
SearchKey::Undeleted => !m.deleted,
SearchKey::Recent => false,
SearchKey::New => false, // RECENT && UNSEEN — we never flag \Recent
SearchKey::Old => true,
SearchKey::Keyword => false,
SearchKey::Unkeyword => true,
SearchKey::Subject(s) => contains_ci(m.subject, s),
SearchKey::From(s) => contains_ci(&m.from, s),
SearchKey::To(s) => contains_ci(&m.to, s),
SearchKey::Cc(s) => contains_ci(&m.cc, s),
SearchKey::Bcc(s) => contains_ci(&m.bcc, s),
SearchKey::Body(s) => m.body.is_some_and(|b| contains_ci(b, s)),
SearchKey::Text(s) => {
contains_ci(&m.headers, s) || m.body.is_some_and(|b| contains_ci(b, s))
}
SearchKey::Header(name, val) => header_contains(&m.headers, name, val),
SearchKey::Before(d) => day_number(m.date_ms) < *d,
SearchKey::On(d) => day_number(m.date_ms) == *d,
SearchKey::Since(d) => day_number(m.date_ms) >= *d,
SearchKey::SentBefore(d) => day_number(m.sent_ms) < *d,
SearchKey::SentOn(d) => day_number(m.sent_ms) == *d,
SearchKey::SentSince(d) => day_number(m.sent_ms) >= *d,
SearchKey::Larger(n) => m.size > *n,
SearchKey::Smaller(n) => m.size < *n,
SearchKey::Uid(set) => set.contains(m.uid),
SearchKey::Sequence(set) => set.contains(m.seq),
}
}
// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq)]
enum Token {
LParen,
RParen,
/// A bare atom (a keyword or an unquoted argument like a date or seq-set).
Atom(String),
/// A double-quoted string argument (may hold spaces / UTF-8).
Quoted(String),
}
fn tokenize(input: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
while let Some(&c) = chars.peek() {
match c {
' ' | '\t' | '\r' | '\n' => {
chars.next();
}
'(' => {
chars.next();
tokens.push(Token::LParen);
}
')' => {
chars.next();
tokens.push(Token::RParen);
}
'"' => {
chars.next(); // opening quote
let mut s = String::new();
while let Some(ch) = chars.next() {
match ch {
'\\' => {
if let Some(escaped) = chars.next() {
s.push(escaped);
}
}
'"' => break,
_ => s.push(ch),
}
}
tokens.push(Token::Quoted(s));
}
_ => {
let mut s = String::new();
while let Some(&ch) = chars.peek() {
if ch == ' ' || ch == '\t' || ch == '(' || ch == ')' || ch == '"' {
break;
}
s.push(ch);
chars.next();
}
tokens.push(Token::Atom(s));
}
}
}
tokens
}
/// Parse a full SEARCH argument string into a single composite key.
pub fn parse(args: &str) -> SearchKey {
let tokens = tokenize(args);
let mut i = 0;
let keys = parse_key_list(&tokens, &mut i, false);
fold_and(keys)
}
fn fold_and(mut keys: Vec<SearchKey>) -> SearchKey {
match keys.len() {
0 => SearchKey::All,
1 => keys.pop().unwrap(),
_ => SearchKey::And(keys),
}
}
/// Parse a run of keys until end-of-input or (when `in_group`) the matching
/// `)`. Consumes the closing paren.
fn parse_key_list(tokens: &[Token], i: &mut usize, in_group: bool) -> Vec<SearchKey> {
let mut keys = Vec::new();
while *i < tokens.len() {
if tokens[*i] == Token::RParen {
*i += 1; // consume ')'
if in_group {
break;
}
continue; // stray ')' at top level — ignore
}
match parse_key(tokens, i) {
Some(k) => keys.push(k),
None => break,
}
}
keys
}
/// Read the next token as a string argument (atom or quoted), advancing.
fn next_arg(tokens: &[Token], i: &mut usize) -> Option<String> {
match tokens.get(*i) {
Some(Token::Atom(s)) | Some(Token::Quoted(s)) => {
let s = s.clone();
*i += 1;
Some(s)
}
_ => None,
}
}
fn parse_key(tokens: &[Token], i: &mut usize) -> Option<SearchKey> {
let token = tokens.get(*i)?.clone();
*i += 1;
match token {
Token::LParen => Some(fold_and(parse_key_list(tokens, i, true))),
Token::RParen => None,
// A stray quoted string in key position can't be a criterion; ignore it
// non-restrictively.
Token::Quoted(_) => Some(SearchKey::All),
Token::Atom(atom) => Some(parse_atom_key(&atom, tokens, i)),
}
}
fn parse_atom_key(atom: &str, tokens: &[Token], i: &mut usize) -> SearchKey {
let key = atom.to_uppercase();
match key.as_str() {
"ALL" => SearchKey::All,
"ANSWERED" => SearchKey::Answered,
"UNANSWERED" => SearchKey::Unanswered,
"SEEN" => SearchKey::Seen,
"UNSEEN" => SearchKey::Unseen,
"FLAGGED" => SearchKey::Flagged,
"UNFLAGGED" => SearchKey::Unflagged,
"DELETED" => SearchKey::Deleted,
"UNDELETED" => SearchKey::Undeleted,
"DRAFT" => SearchKey::Draft,
"UNDRAFT" => SearchKey::Undraft,
"RECENT" => SearchKey::Recent,
"NEW" => SearchKey::New,
"OLD" => SearchKey::Old,
"OR" => {
let a = parse_key(tokens, i).unwrap_or(SearchKey::All);
let b = parse_key(tokens, i).unwrap_or(SearchKey::All);
SearchKey::Or(Box::new(a), Box::new(b))
}
"NOT" => {
let a = parse_key(tokens, i).unwrap_or(SearchKey::All);
SearchKey::Not(Box::new(a))
}
// `CHARSET <name>` is a prefix, not a criterion — skip the name and
// parse the key that follows.
"CHARSET" => {
let _ = next_arg(tokens, i);
parse_key(tokens, i).unwrap_or(SearchKey::All)
}
"SUBJECT" => str_key(tokens, i, SearchKey::Subject),
"FROM" => str_key(tokens, i, SearchKey::From),
"TO" => str_key(tokens, i, SearchKey::To),
"CC" => str_key(tokens, i, SearchKey::Cc),
"BCC" => str_key(tokens, i, SearchKey::Bcc),
"BODY" => str_key(tokens, i, SearchKey::Body),
"TEXT" => str_key(tokens, i, SearchKey::Text),
"KEYWORD" => {
let _ = next_arg(tokens, i);
SearchKey::Keyword
}
"UNKEYWORD" => {
let _ = next_arg(tokens, i);
SearchKey::Unkeyword
}
"HEADER" => {
let name = next_arg(tokens, i).unwrap_or_default();
let value = next_arg(tokens, i).unwrap_or_default();
SearchKey::Header(name, value)
}
"BEFORE" => date_key(tokens, i, SearchKey::Before),
"ON" => date_key(tokens, i, SearchKey::On),
"SINCE" => date_key(tokens, i, SearchKey::Since),
"SENTBEFORE" => date_key(tokens, i, SearchKey::SentBefore),
"SENTON" => date_key(tokens, i, SearchKey::SentOn),
"SENTSINCE" => date_key(tokens, i, SearchKey::SentSince),
"LARGER" => size_key(tokens, i, SearchKey::Larger),
"SMALLER" => size_key(tokens, i, SearchKey::Smaller),
"UID" => {
let set = next_arg(tokens, i)
.map(|s| parse_seqset(&s))
.unwrap_or_default();
SearchKey::Uid(set)
}
// Not a recognised keyword: a bare sequence set, or something we don't
// model. A seq-set becomes a Sequence predicate; anything else degrades
// to a non-restrictive match so we never hide results.
_ => {
if is_seqset(atom) {
SearchKey::Sequence(parse_seqset(atom))
} else {
SearchKey::All
}
}
}
}
fn str_key(tokens: &[Token], i: &mut usize, ctor: fn(String) -> SearchKey) -> SearchKey {
ctor(next_arg(tokens, i).unwrap_or_default())
}
fn date_key(tokens: &[Token], i: &mut usize, ctor: fn(i64) -> SearchKey) -> SearchKey {
match next_arg(tokens, i).and_then(|s| parse_imap_date(&s)) {
Some(day) => ctor(day),
None => SearchKey::All,
}
}
fn size_key(tokens: &[Token], i: &mut usize, ctor: fn(u64) -> SearchKey) -> SearchKey {
match next_arg(tokens, i).and_then(|s| s.parse::<u64>().ok()) {
Some(n) => ctor(n),
None => SearchKey::All,
}
}
fn is_seqset(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_digit() || c == ':' || c == ',' || c == '*')
}
fn parse_seqset(s: &str) -> SeqSet {
let mut ranges = Vec::new();
for part in s.split(',') {
if part.is_empty() {
continue;
}
if let Some((a, b)) = part.split_once(':') {
let start = if a == "*" { 0 } else { a.parse().unwrap_or(0) };
let end = if b == "*" { None } else { b.parse().ok() };
ranges.push(SeqRange { start, end });
} else if part == "*" {
ranges.push(SeqRange {
start: 0,
end: None,
});
} else if let Ok(n) = part.parse::<u32>() {
ranges.push(SeqRange {
start: n,
end: Some(n),
});
}
}
SeqSet { ranges }
}
/// Parse an IMAP date (`dd-Mon-yyyy`, e.g. `1-Feb-2020`) into a day number.
fn parse_imap_date(s: &str) -> Option<i64> {
let parts: Vec<&str> = s.split('-').collect();
if parts.len() != 3 {
return None;
}
let day: i64 = parts[0].parse().ok()?;
let month = match parts[1].to_lowercase().as_str() {
"jan" => 1,
"feb" => 2,
"mar" => 3,
"apr" => 4,
"may" => 5,
"jun" => 6,
"jul" => 7,
"aug" => 8,
"sep" => 9,
"oct" => 10,
"nov" => 11,
"dec" => 12,
_ => return None,
};
let year: i64 = parts[2].parse().ok()?;
Some(days_from_civil(year, month, day))
}
/// Days since 1970-01-01 (Howard Hinnant's `days_from_civil`).
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
#[cfg(test)]
mod tests {
use super::*;
fn day(y: i64, m: i64, d: i64) -> i64 {
days_from_civil(y, m, d)
}
fn view() -> MsgView<'static> {
MsgView {
seq: 1,
uid: 10,
subject: "Hello World",
from: "Alice <alice@example.com>".into(),
to: "Bob <bob@example.com>".into(),
cc: String::new(),
bcc: String::new(),
headers:
"From: Alice <alice@example.com>\r\nSubject: Hello World\r\nMessage-ID: <abc@x>"
.into(),
date_ms: (day(2022, 9, 22) as u64) * 86_400_000 + 3_600_000,
sent_ms: (day(2022, 9, 22) as u64) * 86_400_000 + 3_600_000,
unread: true,
deleted: false,
size: 5000,
body: Some("the quick brown fox"),
}
}
// --- tokenizer ---
#[test]
fn tokenize_quoted_and_atoms() {
let t = tokenize(r#"SUBJECT "hello world" UNSEEN"#);
assert_eq!(
t,
vec![
Token::Atom("SUBJECT".into()),
Token::Quoted("hello world".into()),
Token::Atom("UNSEEN".into()),
]
);
}
#[test]
fn tokenize_parens() {
let t = tokenize("(OR A B)");
assert_eq!(t[0], Token::LParen);
assert_eq!(t[4], Token::RParen);
}
// --- parser ---
#[test]
fn parse_all_when_empty() {
assert_eq!(parse(""), SearchKey::All);
assert_eq!(parse("ALL"), SearchKey::All);
}
#[test]
fn parse_implicit_and() {
let k = parse(r#"UNSEEN SUBJECT "foo""#);
assert_eq!(
k,
SearchKey::And(vec![SearchKey::Unseen, SearchKey::Subject("foo".into())])
);
}
#[test]
fn parse_or_and_not() {
let k = parse(r#"OR FROM "a" NOT SEEN"#);
match k {
SearchKey::Or(a, b) => {
assert_eq!(*a, SearchKey::From("a".into()));
assert_eq!(*b, SearchKey::Not(Box::new(SearchKey::Seen)));
}
_ => panic!("expected OR, got {k:?}"),
}
}
#[test]
fn parse_group() {
let k = parse(r#"(SUBJECT "x" SUBJECT "y") UNSEEN"#);
assert_eq!(
k,
SearchKey::And(vec![
SearchKey::And(vec![
SearchKey::Subject("x".into()),
SearchKey::Subject("y".into()),
]),
SearchKey::Unseen,
])
);
}
#[test]
fn parse_charset_prefix_is_skipped() {
let k = parse(r#"CHARSET UTF-8 BODY "x""#);
assert_eq!(k, SearchKey::Body("x".into()));
}
#[test]
fn parse_header() {
let k = parse(r#"HEADER Message-ID "<abc@x>""#);
assert_eq!(k, SearchKey::Header("Message-ID".into(), "<abc@x>".into()));
}
#[test]
fn parse_date() {
assert_eq!(parse("SINCE 1-Feb-2020"), SearchKey::Since(day(2020, 2, 1)));
assert_eq!(
parse("BEFORE 15-Dec-2021"),
SearchKey::Before(day(2021, 12, 15))
);
}
#[test]
fn parse_bad_date_degrades_to_all() {
assert_eq!(parse("SINCE not-a-date"), SearchKey::All);
}
#[test]
fn parse_seqset_and_uid() {
assert_eq!(parse("1:5"), SearchKey::Sequence(parse_seqset("1:5")));
assert_eq!(parse("UID 3:*"), SearchKey::Uid(parse_seqset("3:*")));
}
#[test]
fn parse_unknown_key_is_non_restrictive() {
assert_eq!(parse("XFOOBAR"), SearchKey::All);
}
// --- seqset ---
#[test]
fn seqset_membership() {
let s = parse_seqset("1,3,5:9");
assert!(s.contains(1));
assert!(!s.contains(2));
assert!(s.contains(3));
assert!(s.contains(7));
assert!(!s.contains(10));
}
#[test]
fn seqset_open_ended_star() {
let s = parse_seqset("100:*");
assert!(!s.contains(99));
assert!(s.contains(100));
assert!(s.contains(999_999));
}
// --- matcher ---
#[test]
fn match_subject_ci() {
assert!(matches(&SearchKey::Subject("hello".into()), &view()));
assert!(matches(&SearchKey::Subject("WORLD".into()), &view()));
assert!(!matches(&SearchKey::Subject("nope".into()), &view()));
}
#[test]
fn match_from_to() {
assert!(matches(&SearchKey::From("alice".into()), &view()));
assert!(matches(&SearchKey::To("bob@example".into()), &view()));
assert!(!matches(&SearchKey::Cc("anyone".into()), &view()));
}
#[test]
fn match_flags_consistent_with_fetch() {
let v = view(); // unread, not deleted
assert!(matches(&SearchKey::Unseen, &v));
assert!(!matches(&SearchKey::Seen, &v));
assert!(!matches(&SearchKey::Answered, &v));
assert!(matches(&SearchKey::Unanswered, &v));
assert!(!matches(&SearchKey::Flagged, &v));
assert!(!matches(&SearchKey::Deleted, &v));
assert!(matches(&SearchKey::Undeleted, &v));
}
#[test]
fn match_dates() {
let v = view(); // 2022-09-22
assert!(matches(&SearchKey::Since(day(2022, 1, 1)), &v));
assert!(matches(&SearchKey::Before(day(2023, 1, 1)), &v));
assert!(matches(&SearchKey::On(day(2022, 9, 22)), &v));
assert!(!matches(&SearchKey::On(day(2022, 9, 23)), &v));
assert!(!matches(&SearchKey::Since(day(2023, 1, 1)), &v));
}
#[test]
fn match_size() {
let v = view(); // size 5000
assert!(matches(&SearchKey::Larger(4000), &v));
assert!(!matches(&SearchKey::Larger(6000), &v));
assert!(matches(&SearchKey::Smaller(6000), &v));
}
#[test]
fn match_body_and_text() {
let v = view();
assert!(matches(&SearchKey::Body("brown".into()), &v));
assert!(!matches(&SearchKey::Body("missing".into()), &v));
// TEXT spans headers + body.
assert!(matches(&SearchKey::Text("Message-ID".into()), &v));
assert!(matches(&SearchKey::Text("quick".into()), &v));
}
#[test]
fn match_body_without_loaded_body_never_matches() {
let mut v = view();
v.body = None;
assert!(!matches(&SearchKey::Body("brown".into()), &v));
// TEXT still matches on headers.
assert!(matches(&SearchKey::Text("Subject".into()), &v));
}
#[test]
fn match_header() {
let v = view();
assert!(matches(
&SearchKey::Header("message-id".into(), "abc".into()),
&v
));
assert!(!matches(
&SearchKey::Header("message-id".into(), "zzz".into()),
&v
));
// Presence-only (empty value).
assert!(matches(&SearchKey::Header("subject".into(), "".into()), &v));
assert!(!matches(&SearchKey::Header("x-nope".into(), "".into()), &v));
}
#[test]
fn match_uid_and_seq() {
let v = view(); // seq 1, uid 10
assert!(matches(&SearchKey::Uid(parse_seqset("5:15")), &v));
assert!(!matches(&SearchKey::Uid(parse_seqset("1:5")), &v));
assert!(matches(&SearchKey::Sequence(parse_seqset("1")), &v));
}
#[test]
fn match_boolean_composition() {
let v = view();
let k = parse(r#"UNSEEN SUBJECT "hello""#);
assert!(matches(&k, &v));
let k = parse(r#"SEEN SUBJECT "hello""#);
assert!(!matches(&k, &v));
let k = parse(r#"OR SEEN SUBJECT "hello""#);
assert!(matches(&k, &v));
let k = parse("NOT SEEN");
assert!(matches(&k, &v));
}
}
+119 -17
View File
@@ -1,9 +1,10 @@
use log::{debug, info};
use std::sync::Arc;
use tutasdk::entities::generated::tutanota::{Mail, MailDetails, TutanotaFile};
use tutasdk::entities::generated::tutanota::{Mail, MailAddress, MailDetails, TutanotaFile};
use crate::imap::search::{self, MsgView};
use crate::mail::mail_to_rfc2822;
use crate::mail::rfc2822::{extract_headers, format_internal_date};
use crate::mail::rfc2822::{extract_headers, format_address, format_internal_date};
use crate::sync::MailStore;
use crate::tuta::{FolderInfo, MailBackend};
@@ -463,22 +464,18 @@ impl ImapSession {
return vec![format!("{} NO No mailbox selected\r\n", tag)];
}
let args_upper = args.to_uppercase();
let query = search::parse(args);
let ids: Vec<u32> = if args_upper.contains("UNSEEN") {
self.mails
.iter()
.enumerate()
.filter(|(_, m)| m.mail.unread)
.map(|(i, m)| if uid_mode { m.uid } else { (i + 1) as u32 })
.collect()
} else {
self.mails
.iter()
.enumerate()
.map(|(i, m)| if uid_mode { m.uid } else { (i + 1) as u32 })
.collect()
};
let ids: Vec<u32> = self
.mails
.iter()
.enumerate()
.filter(|(i, cached)| {
let view = Self::build_search_view(*i, cached);
search::matches(&query, &view)
})
.map(|(i, cached)| if uid_mode { cached.uid } else { (i + 1) as u32 })
.collect();
let id_str = ids
.iter()
@@ -493,6 +490,65 @@ impl ImapSession {
]
}
/// Project a cached message onto the fields IMAP SEARCH compares. Derived
/// strings (From / To / Cc / Bcc / headers) are owned by the [`MsgView`];
/// the cheap ones (subject, body) borrow straight from `cached`.
fn build_search_view(idx: usize, cached: &CachedMail) -> MsgView<'_> {
let headers = cached
.rfc2822
.as_deref()
.map(extract_headers)
.unwrap_or_default();
let body = cached
.details
.as_ref()
.and_then(|d| d.body.compressedText.as_deref().or(d.body.text.as_deref()));
// To/Cc/Bcc come from details when the body is loaded; otherwise only
// the envelope `firstRecipient` (rendered as To) is available.
let (to, cc, bcc) = match cached.details.as_ref() {
Some(d) => (
join_addrs(&d.recipients.toRecipients),
join_addrs(&d.recipients.ccRecipients),
join_addrs(&d.recipients.bccRecipients),
),
None => (
cached
.mail
.firstRecipient
.as_ref()
.map(format_address)
.unwrap_or_default(),
String::new(),
String::new(),
),
};
let sent_ms = cached
.details
.as_ref()
.map(|d| d.sentDate.as_millis())
.unwrap_or_else(|| cached.mail.receivedDate.as_millis());
MsgView {
seq: (idx + 1) as u32,
uid: cached.uid,
subject: &cached.mail.subject,
from: format_address(&cached.mail.sender),
to,
cc,
bcc,
headers,
date_ms: cached.mail.receivedDate.as_millis(),
sent_ms,
unread: cached.mail.unread,
deleted: cached.deleted,
size: cached.rfc2822.as_ref().map(|r| r.len() as u64).unwrap_or(0),
body,
}
}
async fn cmd_store(&mut self, tag: &str, args: &str, uid_mode: bool) -> Vec<String> {
if self.state != State::Selected {
return vec![format!("{} NO No mailbox selected\r\n", tag)];
@@ -771,6 +827,15 @@ impl ImapSession {
}
}
/// Join a recipient list into a single comparable string for SEARCH.
fn join_addrs(addrs: &[MailAddress]) -> String {
addrs
.iter()
.map(format_address)
.collect::<Vec<_>>()
.join(", ")
}
fn build_fetch_response(seq: usize, cached: &CachedMail, items: &str, uid_mode: bool) -> String {
let items_upper = items.to_uppercase();
let mut parts = Vec::new();
@@ -1941,6 +2006,43 @@ mod tests {
assert!(search_line.contains("2"));
}
#[tokio::test]
async fn test_search_subject_and_from() {
let backend = Arc::new(MockBackend::with_mails(vec![
make_mail("m1", "Invoice March", false),
make_mail("m2", "Holiday photos", true),
make_mail("m3", "Invoice April", false),
]));
let (_store, mut session) = make_session(backend).await;
session.handle_command("A001 LOGIN user pass").await;
session.handle_command("A002 SELECT INBOX").await;
// SUBJECT matches only the two invoices (1 and 3), case-insensitively.
let resp = session
.handle_command(r#"A003 SEARCH SUBJECT "invoice""#)
.await;
assert_eq!(resp[0], "* SEARCH 1 3\r\n");
// Combined with UNSEEN, nothing matches (both invoices are read).
let resp = session
.handle_command(r#"A004 SEARCH SUBJECT "invoice" UNSEEN"#)
.await;
assert_eq!(resp[0], "* SEARCH \r\n");
// FROM matches the shared sender on every message.
let resp = session
.handle_command(r#"A005 SEARCH FROM "sender@tuta.com""#)
.await;
assert_eq!(resp[0], "* SEARCH 1 2 3\r\n");
// A non-matching subject returns an empty set (the regression that
// motivated real SEARCH: it used to return everything).
let resp = session
.handle_command(r#"A006 SEARCH SUBJECT "nonexistent""#)
.await;
assert_eq!(resp[0], "* SEARCH \r\n");
}
#[tokio::test]
async fn test_not_authenticated_rejects_commands() {
let backend = Arc::new(MockBackend::new());