elektromail 0.1.1

A minimal, Rust-based IMAP + SMTP mail server for local development and testing
Documentation
use std::{
    io,
    sync::{Arc, Mutex},
};

use crate::store::{Message, Store};
use tokio::io::AsyncWriteExt;

use super::ImapReader;
use crate::imap::fetch::header_value;
use crate::imap::util::parse_imap_args;

pub(super) async fn send_search(
    writer: &mut ImapReader,
    store: &Arc<Mutex<Store>>,
    user: &str,
    mailbox: &str,
    tokens: &[String],
    use_uid: bool,
) -> io::Result<()> {
    let messages = store
        .lock()
        .expect("store lock poisoned")
        .list(user, mailbox);
    let mut hits = Vec::new();
    for (idx, message) in messages.iter().enumerate() {
        if matches_search_tokens(message, tokens) {
            let id = if use_uid {
                message.uid
            } else {
                match u32::try_from(idx + 1) {
                    Ok(value) => value,
                    Err(_) => continue,
                }
            };
            hits.push(id.to_string());
        }
    }
    if hits.is_empty() {
        write_raw(writer, b"* SEARCH\r\n").await?;
    } else {
        write_raw(
            writer,
            format!("* SEARCH {}\r\n", hits.join(" ")).as_bytes(),
        )
        .await?;
    }
    Ok(())
}

pub(super) fn parse_search_args(line: &str) -> Vec<String> {
    let mut args = parse_imap_args(line);
    if args
        .first()
        .is_some_and(|s| s.eq_ignore_ascii_case("SEARCH"))
    {
        args.remove(0);
    }
    args
}

fn matches_search_tokens(message: &Message, tokens: &[String]) -> bool {
    let mut idx = 0;
    while idx < tokens.len() {
        let (matched, next_idx) = evaluate_search_token(message, tokens, idx);
        if !matched {
            return false;
        }
        if next_idx <= idx {
            return false;
        }
        idx = next_idx;
    }
    true
}

fn evaluate_search_token(message: &Message, tokens: &[String], idx: usize) -> (bool, usize) {
    if idx >= tokens.len() {
        return (true, idx);
    }
    let key = tokens
        .get(idx)
        .map(|s| s.to_ascii_uppercase())
        .unwrap_or_default();
    match key.as_str() {
        "OR" => {
            let (left, next_idx) = evaluate_search_token(message, tokens, idx + 1);
            let (right, next_idx) = evaluate_search_token(message, tokens, next_idx);
            (left || right, next_idx)
        }
        "NOT" => {
            let (matched, next_idx) = evaluate_search_token(message, tokens, idx + 1);
            (!matched, next_idx)
        }
        "ALL" => (true, idx + 1),
        "SEEN" => (message.seen, idx + 1),
        "UNSEEN" => (!message.seen, idx + 1),
        "DELETED" => (message.deleted, idx + 1),
        "FLAGGED" => (message.flagged, idx + 1),
        "ANSWERED" => (message.answered, idx + 1),
        "DRAFT" => (message.draft, idx + 1),
        "LARGER" => {
            let value = tokens.get(idx + 1).and_then(|s| s.parse::<usize>().ok());
            let matched = value.is_some_and(|v| message.data.len() > v);
            (matched, idx + 2)
        }
        "SMALLER" => {
            let value = tokens.get(idx + 1).and_then(|s| s.parse::<usize>().ok());
            let matched = value.is_some_and(|v| message.data.len() < v);
            (matched, idx + 2)
        }
        "ON" => {
            let value = tokens.get(idx + 1).map(String::as_str);
            (date_on_match(message, value), idx + 2)
        }
        "SENTSINCE" => {
            let value = tokens.get(idx + 1).map(String::as_str);
            (sent_date_matches(message, value, true), idx + 2)
        }
        "SENTBEFORE" => {
            let value = tokens.get(idx + 1).map(String::as_str);
            (sent_date_matches(message, value, false), idx + 2)
        }
        "SUBJECT" => {
            let value = tokens.get(idx + 1).map(String::as_str);
            (header_matches(message, "Subject", value), idx + 2)
        }
        "FROM" => {
            let value = tokens.get(idx + 1).map(String::as_str);
            (header_matches(message, "From", value), idx + 2)
        }
        "TO" => {
            let value = tokens.get(idx + 1).map(String::as_str);
            (header_matches(message, "To", value), idx + 2)
        }
        "TEXT" => {
            let value = tokens.get(idx + 1).map(String::as_str);
            (text_matches(message, value), idx + 2)
        }
        "HEADER" => {
            let name = tokens.get(idx + 1).map(String::as_str);
            let value = tokens.get(idx + 2).map(String::as_str);
            if let (Some(name), Some(value)) = (name, value) {
                (header_matches(message, name, Some(value)), idx + 3)
            } else {
                (false, tokens.len())
            }
        }
        "SINCE" => {
            let value = tokens.get(idx + 1).map(String::as_str);
            (date_matches(message, value, true), idx + 2)
        }
        "BEFORE" => {
            let value = tokens.get(idx + 1).map(String::as_str);
            (date_matches(message, value, false), idx + 2)
        }
        _ => (false, idx + 1),
    }
}

fn header_matches(message: &Message, name: &str, value: Option<&str>) -> bool {
    let Some(value) = value else {
        return false;
    };
    let header = header_value(&message.data, name).unwrap_or_default();
    header
        .to_ascii_lowercase()
        .contains(&value.to_ascii_lowercase())
}

fn text_matches(message: &Message, value: Option<&str>) -> bool {
    let Some(value) = value else {
        return false;
    };
    let haystack = String::from_utf8_lossy(&message.data).to_ascii_lowercase();
    haystack.contains(&value.to_ascii_lowercase())
}

fn date_matches(message: &Message, value: Option<&str>, since: bool) -> bool {
    let Some(value) = value else {
        return false;
    };
    let target = parse_imap_date(value);
    let message_date = parse_internal_date_only(&message.internal_date);
    let (Some(target), Some(message_date)) = (target, message_date) else {
        return false;
    };
    if since {
        message_date >= target
    } else {
        message_date < target
    }
}

fn date_on_match(message: &Message, value: Option<&str>) -> bool {
    let Some(value) = value else {
        return false;
    };
    let target = parse_imap_date(value);
    let message_date = parse_internal_date_only(&message.internal_date);
    let (Some(target), Some(message_date)) = (target, message_date) else {
        return false;
    };
    message_date == target
}

fn sent_date_matches(message: &Message, value: Option<&str>, since: bool) -> bool {
    let Some(value) = value else {
        return false;
    };
    let target = parse_imap_date(value);
    let date_header = header_value(&message.data, "Date").unwrap_or_default();
    let message_date = parse_rfc2822_date_only(&date_header);
    let (Some(target), Some(message_date)) = (target, message_date) else {
        return false;
    };
    if since {
        message_date >= target
    } else {
        message_date < target
    }
}

fn parse_rfc2822_date_only(value: &str) -> Option<(i32, u32, u32)> {
    let value = value.trim();
    let value = if let Some((_, rest)) = value.split_once(',') {
        rest.trim()
    } else {
        value
    };
    let mut parts = value.split_whitespace();
    let day = parts.next()?.parse::<u32>().ok()?;
    let mon = parts.next()?;
    let year = parts.next()?.parse::<i32>().ok()?;
    let month = match mon.to_ascii_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,
    };
    Some((year, month, day))
}

fn parse_internal_date_only(value: &str) -> Option<(i32, u32, u32)> {
    let date_part = value.split_whitespace().next()?;
    parse_imap_date(date_part)
}

fn parse_imap_date(value: &str) -> Option<(i32, u32, u32)> {
    let (day, rest) = value.split_once('-')?;
    let (mon, year) = rest.split_once('-')?;
    let day = day.parse::<u32>().ok()?;
    let year = year.parse::<i32>().ok()?;
    let month = match mon.to_ascii_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,
    };
    Some((year, month, day))
}

async fn write_raw(writer: &mut ImapReader, data: &[u8]) -> io::Result<()> {
    writer.get_mut().write_all(data).await
}