email-cli 0.6.6

Agent-friendly email CLI for Resend
use anyhow::{Result, bail};
use std::collections::HashMap;

use crate::app::App;
use crate::cli::{ComposeArgs, ForwardArgs, ReplyArgs, SendArgs};
use crate::helpers::{
    append_signature_html, append_signature_text, build_send_attachments,
    ensure_reply_account_matches, format_forwarded_body, format_sender, forward_subject,
    generate_message_id, normalize_email, normalize_emails, now_timestamp, read_optional_content,
    reply_all_recipients, reply_headers_for_message, reply_recipients, reply_subject,
};
use crate::http::fetch_sent_detail;
use crate::models::{MessageRecord, ReplyHeaders, ResolvedCompose, SendEmailRequest, SentEmail};
use crate::output::print_success_or;

impl App {
    pub fn send(&self, args: SendArgs) -> Result<()> {
        let reply_to_msg = args.compose.reply_to_msg;
        let mut compose = self.resolve_compose(args.compose)?;

        let reply_context = if let Some(msg_id) = reply_to_msg {
            let target = self.get_message(msg_id)?;
            let headers = reply_headers_for_message(&target);
            // Auto-set subject if empty
            if compose.subject.is_empty() {
                compose.subject = reply_subject(&target.subject);
            }
            // If no explicit --to, use reply recipients
            if compose.to.is_empty() {
                compose.to = reply_recipients(&target)?;
            }
            Some((target.id, headers))
        } else {
            None
        };

        let message = self.send_compose(compose, reply_context)?;

        print_success_or(self.format, &message, |message| {
            println!(
                "sent message {} from {} to {}",
                message.id,
                message.account_email,
                message.to.join(", ")
            );
        });

        Ok(())
    }

    pub fn reply(&self, args: ReplyArgs) -> Result<()> {
        let target = self.get_message(args.message_id)?;
        let account = match args.account {
            Some(account) => self.get_account(&normalize_email(&account))?,
            None => self.get_account(&target.account_email)?,
        };
        ensure_reply_account_matches(&target, &account)?;

        let (to, mut cc) = if args.all {
            reply_all_recipients(&target, &account.email)
        } else {
            (reply_recipients(&target)?, Vec::new())
        };
        if to.is_empty() {
            bail!("no recipients for reply (all addresses resolved to self)");
        }

        let mut seen: std::collections::HashSet<String> =
            to.iter().chain(cc.iter()).cloned().collect();
        for addr in normalize_emails(&args.cc) {
            if !addr.is_empty() && seen.insert(addr.clone()) {
                cc.push(addr);
            }
        }
        let bcc: Vec<String> = normalize_emails(&args.bcc)
            .into_iter()
            .filter(|addr| !addr.is_empty() && seen.insert(addr.clone()))
            .collect();

        let subject = reply_subject(&target.subject);
        let compose = ResolvedCompose {
            account,
            to,
            cc,
            bcc,
            subject,
            text: read_optional_content(args.text, args.text_file)?,
            html: read_optional_content(args.html, args.html_file)?,
            attachments: args.attachments,
        };
        let headers = reply_headers_for_message(&target);
        let message = self.send_compose(compose, Some((target.id, headers)))?;

        print_success_or(self.format, &message, |message| {
            println!("replied with message {}", message.id);
        });

        Ok(())
    }

    pub fn forward(&self, args: ForwardArgs) -> Result<()> {
        let target = self.get_message(args.message_id)?;
        let account = match args.account {
            Some(account) => self.get_account(&normalize_email(&account))?,
            None => self.get_account(&target.account_email)?,
        };

        let subject = forward_subject(&target.subject);
        let (text, html) = format_forwarded_body(args.text.as_deref(), &target);

        let compose = ResolvedCompose {
            account,
            to: normalize_emails(&args.to),
            cc: normalize_emails(&args.cc),
            bcc: normalize_emails(&args.bcc),
            subject,
            text,
            html,
            attachments: Vec::new(),
        };
        // No reply context — forwarding intentionally breaks thread (per RFC)
        let message = self.send_compose(compose, None)?;

        print_success_or(self.format, &message, |message| {
            println!("forwarded message {} as {}", args.message_id, message.id);
        });

        Ok(())
    }

    pub fn resolve_compose(&self, compose: ComposeArgs) -> Result<ResolvedCompose> {
        let account = match compose.account {
            Some(account) => self.get_account(&normalize_email(&account))?,
            None => self.default_account()?,
        };
        let text = read_optional_content(compose.text, compose.text_file)?;
        let html = read_optional_content(compose.html, compose.html_file)?;
        if text.is_none() && html.is_none() {
            bail!("one of --text/--text-file or --html/--html-file is required");
        }
        Ok(ResolvedCompose {
            account,
            to: normalize_emails(&compose.to),
            cc: normalize_emails(&compose.cc),
            bcc: normalize_emails(&compose.bcc),
            subject: compose.subject,
            text,
            html,
            attachments: compose.attachments,
        })
    }

    pub fn send_compose(
        &self,
        compose: ResolvedCompose,
        reply_context: Option<(i64, ReplyHeaders)>,
    ) -> Result<MessageRecord> {
        let client = self.client_for_profile(&compose.account.profile_name)?;
        let mut text = compose.text.clone();
        let mut html = compose.html.clone();
        if !compose.account.signature.trim().is_empty() {
            if text.is_some() {
                text = Some(append_signature_text(
                    text.as_deref(),
                    &compose.account.signature,
                ));
            }
            if html.is_some() {
                html = Some(append_signature_html(
                    html.as_deref(),
                    &compose.account.signature,
                ));
            }
        }

        // Generate a unique Message-ID for every outgoing email
        let message_id = generate_message_id(&compose.account.email);

        let mut custom_headers = HashMap::new();
        custom_headers.insert("Message-ID".to_string(), message_id.clone());

        if let Some((_, ref reply)) = reply_context {
            if let Some(in_reply_to) = reply.in_reply_to.as_deref() {
                custom_headers.insert("In-Reply-To".to_string(), in_reply_to.to_string());
            }
            if !reply.references.is_empty() {
                custom_headers.insert("References".to_string(), reply.references.join(" "));
            }
        }

        let headers = if custom_headers.is_empty() {
            None
        } else {
            Some(custom_headers)
        };

        let request = SendEmailRequest {
            from: format_sender(
                compose.account.display_name.as_deref(),
                &compose.account.email,
            ),
            to: compose.to.clone(),
            cc: compose.cc.clone(),
            bcc: compose.bcc.clone(),
            subject: compose.subject.clone(),
            text,
            html,
            headers,
            attachments: build_send_attachments(&compose.attachments)?,
        };
        let idempotency_key = self.outbox_send(&request, &compose.account.email)?;

        match client.send_email(&request, &idempotency_key) {
            Ok(response) => {
                self.outbox_mark_sent(&idempotency_key)?;
                let detail =
                    fetch_sent_detail(&client, &response.id).unwrap_or_else(|| SentEmail {
                        id: response.id.clone(),
                        from: Some(request.from.clone()),
                        to: request.to.clone(),
                        cc: request.cc.clone(),
                        bcc: request.bcc.clone(),
                        reply_to: Vec::new(),
                        subject: Some(request.subject.clone()),
                        created_at: Some(now_timestamp()),
                        last_event: Some("sent".to_string()),
                        html: request.html.clone(),
                        text: request.text.clone(),
                    });
                let reply_headers = reply_context.map(|(_, reply)| reply);
                self.store_sent_message(&compose.account, detail, reply_headers, Some(message_id))
            }
            Err(err) => {
                self.outbox_mark_failed(&idempotency_key, &err.to_string())?;
                Err(err)
            }
        }
    }
}