Files
himalaya/src/imap/envelope/search.rs
T
Clément DOUIN 6ae09790aa chore: cargo fmt
2026-05-20 23:48:27 +02:00

268 lines
8.7 KiB
Rust

// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
// details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use std::fmt;
use anyhow::{Result, anyhow, bail};
use clap::Parser;
use comfy_table::{Cell, Color, ContentArrangement, Row, Table};
use io_imap::types::{
core::{AString, Vec1},
datetime::NaiveDate,
search::SearchKey,
};
use pimalaya_cli::printer::Printer;
use serde::Serialize;
use crate::imap::{
client::ImapClient,
mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag},
};
/// Search IMAP messages by criteria.
///
/// This command searches for messages matching the given criteria and
/// returns a list of matching sequence numbers or UIDs.
///
/// Query syntax (multiple terms are ANDed together):
/// - from:alice - messages from "alice"
/// - to:bob - messages to "bob"
/// - cc:charlie - messages CC'd to "charlie"
/// - bcc:dave - messages BCC'd to "dave"
/// - subject:hello - messages with "hello" in subject
/// - body:keyword - messages with "keyword" in body
/// - text:keyword - messages with "keyword" in header or body
/// - seen - messages that have been read
/// - unseen - messages that have not been read
/// - flagged - messages that are flagged
/// - answered - messages that have been answered
/// - deleted - messages marked for deletion
/// - draft - draft messages
/// - before:2024-01-15 - messages before date
/// - since:2024-01-01 - messages since date
/// - on:2024-01-10 - messages on date
/// - larger:1000 - messages larger than 1000 bytes
/// - smaller:5000 - messages smaller than 5000 bytes
/// - all - all messages
#[derive(Debug, Parser)]
pub struct ImapEnvelopeSearchCommand {
#[command(flatten)]
pub mailbox_name: MailboxNameOptionalFlag,
#[command(flatten)]
pub mailbox_no_select: MailboxNoSelectFlag,
/// Search query (e.g., "from:alice unseen").
#[arg(name = "query", value_name = "QUERY", default_value = "all")]
pub query: String,
/// Use sequence numbers instead of UIDs.
#[arg(long)]
pub seq: bool,
}
impl ImapEnvelopeSearchCommand {
pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> {
let mailbox = self.mailbox_name.inner.try_into()?;
if !self.mailbox_no_select.inner {
client.select(mailbox)?;
}
let criteria = parse_query(&self.query)?;
let ids = client.search(criteria, !self.seq)?;
let table = SearchTable {
preset: client.account.table_preset().to_string(),
arrangement: client.account.table_arrangement(),
id_color: client.account.envelopes_list_table_id_color(),
ids: ids
.into_iter()
.map(|id| SearchResult { id: id.get() })
.collect(),
uid_mode: !self.seq,
};
printer.out(table)
}
}
#[derive(Clone, Debug, Serialize)]
pub struct SearchResult {
pub id: u32,
}
#[derive(Clone, Debug, Serialize)]
pub struct SearchTable {
#[serde(skip)]
preset: String,
#[serde(skip)]
arrangement: ContentArrangement,
#[serde(skip)]
id_color: Color,
uid_mode: bool,
ids: Vec<SearchResult>,
}
impl fmt::Display for SearchTable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut table = Table::new();
let id_header = if self.uid_mode { "UID" } else { "SEQ" };
table
.load_preset(&self.preset)
.set_content_arrangement(self.arrangement.clone())
.set_header(Row::from([Cell::new(id_header)]));
for result in &self.ids {
table.add_row(Row::from([Cell::new(result.id).fg(self.id_color)]));
}
writeln!(f)?;
write!(f, "{table}")?;
writeln!(f)?;
writeln!(f, "Found {} message(s)", self.ids.len())?;
Ok(())
}
}
/// Parse a query string into search criteria.
///
/// Multiple terms are ANDed together.
pub fn parse_query(query: &str) -> Result<Vec1<SearchKey<'static>>> {
let mut keys: Vec<SearchKey<'static>> = Vec::new();
for term in query.split_whitespace() {
let key = parse_term(term)?;
keys.push(key);
}
if keys.is_empty() {
keys.push(SearchKey::All);
}
Ok(Vec1::unvalidated(keys))
}
fn parse_term(term: &str) -> Result<SearchKey<'static>> {
let term_lower = term.to_lowercase();
// Simple flag keywords
match term_lower.as_str() {
"all" => return Ok(SearchKey::All),
"seen" => return Ok(SearchKey::Seen),
"unseen" => return Ok(SearchKey::Unseen),
"flagged" => return Ok(SearchKey::Flagged),
"unflagged" => return Ok(SearchKey::Unflagged),
"answered" => return Ok(SearchKey::Answered),
"unanswered" => return Ok(SearchKey::Unanswered),
"deleted" => return Ok(SearchKey::Deleted),
"undeleted" => return Ok(SearchKey::Undeleted),
"draft" => return Ok(SearchKey::Draft),
"undraft" => return Ok(SearchKey::Undraft),
"new" => return Ok(SearchKey::New),
"old" => return Ok(SearchKey::Old),
"recent" => return Ok(SearchKey::Recent),
_ => {}
}
// Key:value patterns
if let Some((key, value)) = term.split_once(':') {
let key_lower = key.to_lowercase();
let value_str = value.to_string();
match key_lower.as_str() {
"from" => {
let astring = AString::try_from(value_str)?;
return Ok(SearchKey::From(astring));
}
"to" => {
let astring = AString::try_from(value_str)?;
return Ok(SearchKey::To(astring));
}
"cc" => {
let astring = AString::try_from(value_str)?;
return Ok(SearchKey::Cc(astring));
}
"bcc" => {
let astring = AString::try_from(value_str)?;
return Ok(SearchKey::Bcc(astring));
}
"subject" => {
let astring = AString::try_from(value_str)?;
return Ok(SearchKey::Subject(astring));
}
"body" => {
let astring = AString::try_from(value_str)?;
return Ok(SearchKey::Body(astring));
}
"text" => {
let astring = AString::try_from(value_str)?;
return Ok(SearchKey::Text(astring));
}
"before" => {
let date = parse_date(value)?;
return Ok(SearchKey::Before(date));
}
"since" => {
let date = parse_date(value)?;
return Ok(SearchKey::Since(date));
}
"on" => {
let date = parse_date(value)?;
return Ok(SearchKey::On(date));
}
"larger" => {
let size: u32 = value.parse()?;
return Ok(SearchKey::Larger(size));
}
"smaller" => {
let size: u32 = value.parse()?;
return Ok(SearchKey::Smaller(size));
}
_ => {}
}
}
bail!("Unknown search term `{term}`")
}
fn parse_date(s: &str) -> Result<NaiveDate> {
// Parse YYYY-MM-DD format
let parts: Vec<&str> = s.split('-').collect();
if parts.len() != 3 {
bail!("Invalid date format `{s}`, expected YYYY-MM-DD");
}
let year: i32 = parts[0]
.parse()
.map_err(|_| anyhow!("Invalid year in date `{s}`"))?;
let month: u32 = parts[1]
.parse()
.map_err(|_| anyhow!("Invalid month in date `{s}`"))?;
let day: u32 = parts[2]
.parse()
.map_err(|_| anyhow!("Invalid day in date `{s}`"))?;
// Create chrono::NaiveDate first
let chrono_date = chrono::NaiveDate::from_ymd_opt(year, month, day)
.ok_or_else(|| anyhow!("Invalid date `{s}`"))?;
// Convert to imap-types NaiveDate
NaiveDate::try_from(chrono_date).map_err(|e| anyhow!("Invalid date `{s}`: {e}"))
}