simplemailclient 0.0.2

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

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

/// Maximum size for a single attachment (10 MB).
pub const MAX_ATTACHMENT_BYTES: u64 = 10 * 1024 * 1024;

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

/// Send a message. `attachments` is a list of file paths; each must be a
/// text-format file under [`MAX_ATTACHMENT_BYTES`].
pub async fn send_smtp(cfg: &Config, to: &str, body: &str, attachments: &[String]) -> Result<()> {
    let from_addr = match &cfg.display_name {
        Some(name) => format!("{} <{}>", name, cfg.identity),
        None => cfg.identity.clone(),
    };

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

    let email = if attachments.is_empty() {
        // Plain single-part message (unchanged wire format).
        builder
            .body(body.to_string())
            .context("Failed to build email")?
    } else {
        // multipart/mixed: text body first, then each file as a text/plain part.
        let text_ct = ContentType::parse("text/plain; charset=utf-8")
            .context("Invalid content type")?;
        let mut mp = MultiPart::mixed().singlepart(SinglePart::plain(body.to_string()));

        for path in attachments {
            let data = std::fs::read(path)
                .with_context(|| format!("Cannot read attachment: {}", path))?;
            if data.len() as u64 > MAX_ATTACHMENT_BYTES {
                bail!("Attachment exceeds 10 MB limit: {}", path);
            }
            let filename = std::path::Path::new(path)
                .file_name()
                .map(|s| s.to_string_lossy().into_owned())
                .unwrap_or_else(|| "attachment.txt".to_string());
            mp = mp.singlepart(Attachment::new(filename).body(data, text_ct.clone()));
        }

        builder.multipart(mp).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 only UNSEEN messages with our marker subject — ignores regular Gmail
    let uids = imap_session
        .uid_search(format!("UNSEEN SUBJECT \"{}\"", MAIL_SUBJECT))
        .context("IMAP SEARCH failed")?;

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

    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)
        };

        // Parse body text
        let body = if let Some(bytes) = raw.body() {
            // Simple extraction: strip headers, take text body
            extract_body_text(bytes)
        } else {
            String::new()
        };

        if !body.is_empty() {
            store.deliver(&from_addr, &body, display_name.as_deref())?;
        }
    }

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

/// 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.
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.
        if part.ctype.mimetype.starts_with("text/") {
            if let Ok(body) = part.get_body() {
                if !body.trim().is_empty() {
                    return Some(body);
                }
            }
        }
        return None;
    }

    // Multipart: first pass prefers text/plain.
    for sp in &part.subparts {
        if sp.ctype.mimetype == "text/plain" {
            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
}

/// 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)
}