simplemailclient 0.1.1

A simple terminal mail client (SMTP send, IMAP fetch) with a TUI.
use anyhow::{Context, Result};
use lettre::{
    message::Message as LettreMessage,
    transport::smtp::authentication::Credentials,
    AsyncSmtpTransport, AsyncTransport, Tokio1Executor,
};
use crate::config::Config;
use crate::store::MailStore;

pub const MAIL_SUBJECT: &str = "[mailrs_mail]";

// ─── SMTP Send ────────────────────────────────────────────────────────────────

/// Send a plain-text message via SMTP.
pub async fn send_smtp(cfg: &Config, to: &str, body: &str) -> Result<()> {
    let from_addr = match &cfg.display_name {
        Some(name) => format!("{} <{}>", name, cfg.identity),
        None => cfg.identity.clone(),
    };

    let email = LettreMessage::builder()
        .from(from_addr.parse().context("Invalid from address")?)
        .to(to.parse().context("Invalid to address")?)
        .subject(MAIL_SUBJECT)
        .body(body.to_string())
        .context("Failed to build email")?;

    let creds = Credentials::new(
        cfg.smtp.username.clone(),
        cfg.smtp.password.clone(),
    );

    let transport: AsyncSmtpTransport<Tokio1Executor> = if cfg.smtp.tls {
        AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.smtp.host)
            .context("SMTP STARTTLS relay failed")?
            .port(cfg.smtp.port)
            .credentials(creds)
            .build()
    } else {
        AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.smtp.host)
            .port(cfg.smtp.port)
            .credentials(creds)
            .build()
    };

    transport
        .send(email)
        .await
        .context("SMTP send failed")?;

    Ok(())
}

// ─── IMAP Fetch ───────────────────────────────────────────────────────────────

pub async fn fetch_imap(cfg: &Config, store: &MailStore) -> Result<()> {
    use std::net::TcpStream;
    use native_tls::TlsConnector;
    use imap::Client;

    let domain = cfg.imap.host.clone();
    let port = cfg.imap.port;

    let tls = TlsConnector::builder()
        .build()
        .context("TLS connector failed")?;

    let addr = format!("{}:{}", domain, port);
    let stream = TcpStream::connect(&addr)
        .context("TCP connection failed")?;

    let tls_stream = tls.connect(&domain, stream)
        .context("IMAP TLS handshake failed")?;

    let client = Client::new(tls_stream);

    let mut imap_session = client
        .login(&cfg.imap.username, &cfg.imap.password)
        .map_err(|(err, _)| err)
        .context("IMAP login failed")?;

    imap_session
        .select(&cfg.imap.inbox_folder)
        .context("IMAP SELECT failed")?;

    // Fetch all messages (read or unread) with our marker subject — keeps full history
    let uids_set = imap_session
        .uid_search(format!("SUBJECT \"{}\"", MAIL_SUBJECT))
        .context("IMAP SEARCH failed")?;

    if uids_set.is_empty() {
        imap_session.logout().ok();
        return Ok(());
    }

    // Convert to sorted vec and keep only the last 50 (most recent)
    let mut uids: Vec<u32> = uids_set.into_iter().collect();
    uids.sort();
    if uids.len() > 50 {
        uids = uids.into_iter().rev().take(50).collect::<Vec<_>>();
        uids.reverse(); // Re-sort ascending for IMAP
    }

    let uid_set: String = uids
        .iter()
        .map(|u| u.to_string())
        .collect::<Vec<_>>()
        .join(",");

    let messages = imap_session
        .uid_fetch(&uid_set, "(RFC822 ENVELOPE)")
        .context("IMAP FETCH failed")?;

    for raw in messages.iter() {
        // Parse sender from envelope
        let (from_addr, display_name) = if let Some(envelope) = raw.envelope() {
            parse_envelope_from(envelope)
        } else {
            ("unknown@unknown".to_string(), None)
        };

        // Extract message-id from headers for deduplication
        let uid = raw.uid; // field, not method
        let (body, message_id) = if let Some(bytes) = raw.body() {
            let body_text = extract_body_text(bytes);
            let mid = extract_message_id(bytes);
            (body_text, mid)
        } else {
            (String::new(), None)
        };

        // Skip empty messages and already-known message-ids
        if body.is_empty() {
            continue;
        }

        store.deliver(&from_addr, &body, display_name.as_deref(), uid, message_id)?;
    }

    imap_session.logout().ok();
    Ok(())
}

// ─── IMAP Trash Move ──────────────────────────────────────────────────────────

/// Move a message by UID from the inbox to the configured trash folder on the
/// IMAP server.  This is a best-effort operation — the caller should still
/// update local state regardless of whether this succeeds.
pub async fn imap_move_to_trash(cfg: &Config, uid: u32) -> Result<()> {
    use std::net::TcpStream;
    use native_tls::TlsConnector;
    use imap::Client;

    let domain = cfg.imap.host.clone();
    let port = cfg.imap.port;

    let tls = TlsConnector::builder()
        .build()
        .context("TLS connector failed")?;

    let addr = format!("{}:{}", domain, port);
    let stream = TcpStream::connect(&addr)
        .context("TCP connection failed")?;

    let tls_stream = tls.connect(&domain, stream)
        .context("IMAP TLS handshake failed")?;

    let client = Client::new(tls_stream);

    let mut session = client
        .login(&cfg.imap.username, &cfg.imap.password)
        .map_err(|(err, _)| err)
        .context("IMAP login failed")?;

    session
        .select(&cfg.imap.inbox_folder)
        .context("IMAP SELECT failed")?;

    let uid_str = uid.to_string();

    // COPY to trash folder, then mark original as \Deleted and expunge
    session
        .uid_copy(&uid_str, &cfg.imap.trash_folder)
        .context("IMAP UID COPY to trash failed")?;

    session
        .uid_store(&uid_str, "+FLAGS (\\Deleted)")
        .context("IMAP UID STORE \\Deleted failed")?;

    session.expunge().context("IMAP EXPUNGE failed")?;

    session.logout().ok();
    Ok(())
}

// ─── MIME helpers ─────────────────────────────────────────────────────────────

/// Clean a body string that was previously stored as a raw MIME fragment
/// (body-only, no outer RFC822 headers). Reconstructs the missing outer
/// headers by detecting the boundary from the first `--<id>` line, then
/// delegates to the normal extractor.
pub fn extract_body_text_pub(raw: &[u8]) -> String {
    let text = String::from_utf8_lossy(raw);

    // Find the boundary: the first line starting with "--" followed by
    // non-whitespace hex characters.
    let boundary = text.lines().find_map(|line| {
        let line = line.trim();
        if line.starts_with("--") && line.len() > 2 && !line.ends_with("--") {
            Some(line[2..].to_string())
        } else {
            None
        }
    });

    if let Some(b) = boundary {
        let wrapped = format!(
            "From: x\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=\"{}\"\r\n\r\n{}",
            b, text
        );
        let result = extract_body_text(wrapped.as_bytes());
        if !result.is_empty() {
            return result;
        }
    }

    // Not actually MIME multipart — return as-is.
    text.trim().to_string()
}

/// Extract the readable plain-text body from a raw RFC822 message.
///
/// Handles MIME multipart (preferring the `text/plain` part), and decodes
/// transfer encodings (quoted-printable / base64) and charset via `mailparse`.
/// Falls back to naive header-stripping if the message can't be parsed.
fn extract_body_text(raw: &[u8]) -> String {
    match mailparse::parse_mail(raw) {
        Ok(parsed) => extract_text_from_part(&parsed)
            .map(|s| s.trim().to_string())
            .unwrap_or_default(),
        Err(_) => {
            let text = String::from_utf8_lossy(raw);
            if let Some(pos) = text.find("\r\n\r\n") {
                text[pos + 4..].trim().to_string()
            } else if let Some(pos) = text.find("\n\n") {
                text[pos + 2..].trim().to_string()
            } else {
                text.trim().to_string()
            }
        }
    }
}

/// Walk a parsed MIME tree and return the best text body: prefer `text/plain`,
/// then any other `text/*` part, recursing into nested multiparts.
/// Skip any parts marked as attachments (have a filename parameter).
fn extract_text_from_part(part: &mailparse::ParsedMail) -> Option<String> {
    if part.subparts.is_empty() {
        // Leaf part: return its decoded body if it's textual and not an attachment.
        if part.ctype.mimetype.starts_with("text/") && !is_attachment(part) {
            if let Ok(body) = part.get_body() {
                if !body.trim().is_empty() {
                    return Some(body);
                }
            }
        }
        return None;
    }

    // Multipart: first pass prefers text/plain (that aren't attachments).
    for sp in &part.subparts {
        if sp.ctype.mimetype == "text/plain" && !is_attachment(sp) {
            if let Ok(body) = sp.get_body() {
                if !body.trim().is_empty() {
                    return Some(body);
                }
            }
        }
    }
    // Second pass: recurse (handles multipart/alternative and the like).
    for sp in &part.subparts {
        if let Some(body) = extract_text_from_part(sp) {
            return Some(body);
        }
    }
    None
}

/// Check if a MIME part is an attachment (has a filename parameter).
fn is_attachment(part: &mailparse::ParsedMail) -> bool {
    part.ctype.params.get("filename").is_some()
}

/// Extract the RFC 2822 Message-ID header from a raw message, if present.
fn extract_message_id(raw: &[u8]) -> Option<String> {
    match mailparse::parse_mail(raw) {
        Ok(parsed) => parsed
            .headers
            .iter()
            .find(|h| h.get_key_ref().eq_ignore_ascii_case("message-id"))
            .map(|h| h.get_value().trim().to_string()),
        Err(_) => {
            // Fallback: scan the raw bytes for the header line
            let text = String::from_utf8_lossy(raw);
            for line in text.lines() {
                if line.to_ascii_lowercase().starts_with("message-id:") {
                    return Some(line[11..].trim().to_string());
                }
                // Stop at blank line (end of headers)
                if line.trim().is_empty() {
                    break;
                }
            }
            None
        }
    }
}

/// Pull the first From address out of an IMAP envelope.
fn parse_envelope_from(envelope: &imap_proto::types::Envelope) -> (String, Option<String>) {
    if let Some(addresses) = &envelope.from {
        if let Some(addr) = addresses.first() {
            let mailbox = addr
                .mailbox
                .as_ref()
                .map(|b| String::from_utf8_lossy(b).to_string())
                .unwrap_or_default();
            let host = addr
                .host
                .as_ref()
                .map(|b| String::from_utf8_lossy(b).to_string())
                .unwrap_or_default();
            let name = addr.name.as_ref().map(|b| String::from_utf8_lossy(b).to_string());
            return (format!("{}@{}", mailbox, host), name);
        }
    }
    ("unknown@unknown".to_string(), None)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_mime_migration() {
        let raw = "--000000000000626b900653087fa1\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nRem sama sana duydu=C4=9Fum duygular...\r\n\r\n--000000000000626b900653087fa1\r\nContent-Type: text/html; charset=\"UTF-8\"\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n<div>Rem sama sana duydu=C4=9Fum duygular...</div>\r\n\r\n--000000000000626b900653087fa1--\r\n";
        let result = extract_body_text_pub(raw.as_bytes());
        assert_eq!(result, "Rem sama sana duyduğum duygular...", "got: {:?}", result);
    }
}