email-cli 0.1.1

Agent-friendly email CLI for Resend
use anyhow::{Result, bail};
use rusqlite::params;

use crate::app::App;
use crate::cli::{InboxListArgs, InboxReadArgs, InboxDeleteArgs, InboxArchiveArgs, InboxSearchArgs, InboxPurgeArgs};
use crate::helpers::compact_targets;
use crate::output::print_success_or;

fn strip_ansi(text: &str) -> String {
    let mut result = String::with_capacity(text.len());
    let mut chars = text.chars().peekable();
    while let Some(ch) = chars.next() {
        if ch == '\x1b' {
            if chars.peek() == Some(&'[') {
                chars.next();
                while let Some(&next) = chars.peek() {
                    chars.next();
                    if next.is_ascii_alphabetic() || next == '~' {
                        break;
                    }
                }
            }
        } else {
            result.push(ch);
        }
    }
    result
}

fn strip_html_tags(html: &str) -> String {
    let mut result = String::with_capacity(html.len());
    let mut in_tag = false;
    for ch in html.chars() {
        match ch {
            '<' => in_tag = true,
            '>' => in_tag = false,
            _ if !in_tag => result.push(ch),
            _ => {}
        }
    }
    let mut prev_blank = false;
    let mut cleaned = String::new();
    for line in result.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            if !prev_blank {
                cleaned.push('\n');
                prev_blank = true;
            }
        } else {
            cleaned.push_str(trimmed);
            cleaned.push('\n');
            prev_blank = false;
        }
    }
    cleaned
}

impl App {
    pub fn inbox_list(&self, args: InboxListArgs) -> Result<()> {
        let archived_val: i64 = if args.archived { 1 } else { 0 };
        let (sql, has_account) = match (args.account.as_deref(), args.unread) {
            (Some(_), true) => (
                "SELECT id, remote_id, direction, account_email, from_addr, to_json, cc_json, bcc_json,
                        reply_to_json, subject, text_body, html_body, rfc_message_id, in_reply_to,
                        references_json, last_event, is_read, created_at, synced_at
                 FROM messages
                 WHERE account_email = ?1 AND is_read = 0 AND archived = ?2
                 ORDER BY created_at DESC
                 LIMIT ?3",
                true,
            ),
            (Some(_), false) => (
                "SELECT id, remote_id, direction, account_email, from_addr, to_json, cc_json, bcc_json,
                        reply_to_json, subject, text_body, html_body, rfc_message_id, in_reply_to,
                        references_json, last_event, is_read, created_at, synced_at
                 FROM messages
                 WHERE account_email = ?1 AND archived = ?2
                 ORDER BY created_at DESC
                 LIMIT ?3",
                true,
            ),
            (None, true) => (
                "SELECT id, remote_id, direction, account_email, from_addr, to_json, cc_json, bcc_json,
                        reply_to_json, subject, text_body, html_body, rfc_message_id, in_reply_to,
                        references_json, last_event, is_read, created_at, synced_at
                 FROM messages
                 WHERE is_read = 0 AND archived = ?1
                 ORDER BY created_at DESC
                 LIMIT ?2",
                false,
            ),
            (None, false) => (
                "SELECT id, remote_id, direction, account_email, from_addr, to_json, cc_json, bcc_json,
                        reply_to_json, subject, text_body, html_body, rfc_message_id, in_reply_to,
                        references_json, last_event, is_read, created_at, synced_at
                 FROM messages
                 WHERE archived = ?1
                 ORDER BY created_at DESC
                 LIMIT ?2",
                false,
            ),
        };
        let mut stmt = self.conn.prepare(sql)?;
        let rows = if has_account {
            stmt.query_map(
                params![crate::helpers::normalize_email(args.account.as_deref().unwrap()), archived_val, args.limit as i64],
                crate::db::map_message,
            )?
        } else {
            stmt.query_map(params![archived_val, args.limit as i64], crate::db::map_message)?
        };
        let messages: Vec<_> = rows.collect::<std::result::Result<Vec<_>, _>>()?;

        print_success_or(self.format, &messages, |messages| {
            for message in messages {
                let read_flag = if message.is_read { " " } else { "*" };
                println!(
                    "{}{} [{}] {} -> {} | {}",
                    message.id,
                    read_flag,
                    message.direction,
                    message.account_email,
                    compact_targets(&message.to),
                    message.subject
                );
            }
        });

        Ok(())
    }

    pub fn inbox_read(&self, args: InboxReadArgs) -> Result<()> {
        if args.mark_read {
            self.conn.execute(
                "UPDATE messages SET is_read = 1 WHERE id = ?1",
                params![args.id],
            )?;
        }
        let raw = args.raw;
        let message = self.get_message(args.id)?;

        print_success_or(self.format, &message, |message| {
            println!("id: {}", message.id);
            println!("account: {}", message.account_email);
            println!("direction: {}", message.direction);
            println!("from: {}", message.from_addr);
            println!("to: {}", message.to.join(", "));
            println!("subject: {}", message.subject);
            if let Some(rfc) = message.rfc_message_id.as_deref() {
                println!("message-id: {}", rfc);
            }
            println!();
            if let Some(text) = message.text_body.as_deref() {
                if raw {
                    println!("{}", text);
                } else {
                    println!("{}", strip_ansi(text));
                }
            } else if let Some(html) = message.html_body.as_deref() {
                if raw {
                    println!("{}", html);
                } else {
                    println!("{}", strip_ansi(&strip_html_tags(html)));
                }
            }
        });

        Ok(())
    }

    pub fn inbox_delete(&self, args: InboxDeleteArgs) -> Result<()> {
        let count = self.conn.execute("DELETE FROM messages WHERE id = ?1", params![args.id])?;
        if count == 0 {
            bail!("message {} not found", args.id);
        }
        print_success_or(self.format, &serde_json::json!({"id": args.id, "deleted": true}), |_| {
            println!("deleted message {}", args.id);
        });
        Ok(())
    }

    pub fn inbox_archive(&self, args: InboxArchiveArgs) -> Result<()> {
        let count = self.conn.execute(
            "UPDATE messages SET archived = 1 WHERE id = ?1",
            params![args.id],
        )?;
        if count == 0 {
            bail!("message {} not found", args.id);
        }
        print_success_or(self.format, &serde_json::json!({"id": args.id, "archived": true}), |_| {
            println!("archived message {}", args.id);
        });
        Ok(())
    }

    pub fn inbox_search(&self, args: InboxSearchArgs) -> Result<()> {
        let _ = self.conn.execute_batch(
            "INSERT OR REPLACE INTO messages_fts(messages_fts) VALUES('rebuild');"
        );

        let sql = if args.account.is_some() {
            "SELECT m.id, m.remote_id, m.direction, m.account_email, m.from_addr, m.to_json, m.cc_json, m.bcc_json,
                    m.reply_to_json, m.subject, m.text_body, m.html_body, m.rfc_message_id, m.in_reply_to,
                    m.references_json, m.last_event, m.is_read, m.created_at, m.synced_at
             FROM messages m
             JOIN messages_fts fts ON m.id = fts.rowid
             WHERE messages_fts MATCH ?1 AND m.account_email = ?2
             ORDER BY m.created_at DESC
             LIMIT ?3"
        } else {
            "SELECT m.id, m.remote_id, m.direction, m.account_email, m.from_addr, m.to_json, m.cc_json, m.bcc_json,
                    m.reply_to_json, m.subject, m.text_body, m.html_body, m.rfc_message_id, m.in_reply_to,
                    m.references_json, m.last_event, m.is_read, m.created_at, m.synced_at
             FROM messages m
             JOIN messages_fts fts ON m.id = fts.rowid
             WHERE messages_fts MATCH ?1
             ORDER BY m.created_at DESC
             LIMIT ?2"
        };

        let mut stmt = self.conn.prepare(sql)?;
        let rows = if let Some(account) = &args.account {
            stmt.query_map(
                params![args.query, crate::helpers::normalize_email(account), args.limit as i64],
                crate::db::map_message,
            )?
        } else {
            stmt.query_map(
                params![args.query, args.limit as i64],
                crate::db::map_message,
            )?
        };
        let messages: Vec<_> = rows.collect::<std::result::Result<Vec<_>, _>>()?;

        print_success_or(self.format, &messages, |messages| {
            for message in messages {
                let read_flag = if message.is_read { " " } else { "*" };
                println!(
                    "{}{} [{}] {} | {}",
                    message.id, read_flag, message.direction, message.from_addr, message.subject
                );
            }
            if messages.is_empty() {
                println!("no results");
            }
        });
        Ok(())
    }

    pub fn inbox_purge(&self, args: InboxPurgeArgs) -> Result<()> {
        let count = if let Some(account) = &args.account {
            self.conn.execute(
                "DELETE FROM messages WHERE created_at < ?1 AND account_email = ?2",
                params![args.before, crate::helpers::normalize_email(account)],
            )?
        } else {
            self.conn.execute(
                "DELETE FROM messages WHERE created_at < ?1",
                params![args.before],
            )?
        };
        print_success_or(self.format, &serde_json::json!({"purged": count}), |_| {
            println!("purged {} messages", count);
        });
        Ok(())
    }
}