simplemailclient 0.0.2

A simple terminal mail client (SMTP send, IMAP fetch) with a TUI.
use std::cell::RefCell;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Local};
use uuid::Uuid;
use anyhow::{Context, Result};
use crate::config::Config;

// ─── Domain Types ─────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
    pub id: String,
    pub from: String,
    pub to: String,
    pub body: String,
    pub timestamp: DateTime<Local>,
    pub read: bool,
    pub starred: bool,
    pub folder: Folder,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Folder {
    Inbox,
    Sent,
    Starred,
    Trash,
}

impl Folder {
    pub fn label(&self) -> &'static str {
        match self {
            Folder::Inbox   => "Inbox",
            Folder::Sent    => "Sent",
            Folder::Starred => "Starred",
            Folder::Trash   => "Trash",
        }
    }
}

/// A contact entry.  Contacts are keyed by domain+user and assigned a stable
/// numeric ID.  They are created automatically from any address we send to or
/// receive from.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contact {
    /// Stable numeric ID (e.g. "1", "2", …)
    pub id: String,
    /// Full email address
    pub address: String,
    /// The local part  (user)
    pub user: String,
    /// The domain part
    pub domain: String,
    /// Optional display name, populated if the remote sends a friendly name
    pub display_name: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Default)]
struct MailData {
    messages: Vec<Message>,
    /// address → Contact
    contacts: HashMap<String, Contact>,
    /// monotonic counter for contact IDs
    next_contact_id: u64,
}

// ─── MailStore ────────────────────────────────────────────────────────────────

pub struct MailStore {
    data_path: PathBuf,
    data: RefCell<MailData>,
}

impl MailStore {
    pub fn new(cfg: &Config) -> Result<Self> {
        let data_dir = dirs::data_local_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join("mail-rs");
        fs::create_dir_all(&data_dir)?;
        let data_path = data_dir.join("mailbox.json");

        let data = if data_path.exists() {
            let raw = fs::read_to_string(&data_path)
                .with_context(|| format!("Cannot read {}", data_path.display()))?;
            serde_json::from_str(&raw).unwrap_or_default()
        } else {
            MailData::default()
        };

        // Ensure our own identity is in the contact book
        let store = Self { data_path, data: RefCell::new(data) };
        store.ensure_contact(&cfg.identity)?;
        Ok(store)
    }

    // ── Persistence ──────────────────────────────────────────────────────────

    fn save(&self) -> Result<()> {
        let json = serde_json::to_string_pretty(&*self.data.borrow())?;
        fs::write(&self.data_path, json)?;
        Ok(())
    }

    // ── Contact Management ───────────────────────────────────────────────────

    /// Ensure an address is registered; return its contact.
    /// If new, parse user@domain and assign the next numeric ID.
    pub fn ensure_contact(&self, address: &str) -> Result<Contact> {
        {
            let data = self.data.borrow();
            if let Some(c) = data.contacts.get(address) {
                return Ok(c.clone());
            }
        }

        let (user, domain) = split_address(address);
        let mut data = self.data.borrow_mut();
        data.next_contact_id += 1;
        let contact = Contact {
            id: data.next_contact_id.to_string(),
            address: address.to_string(),
            user,
            domain,
            display_name: None,
        };
        data.contacts.insert(address.to_string(), contact.clone());
        drop(data);
        self.save()?;
        Ok(contact)
    }

    /// Ensure contact and optionally set / update display name.
    pub fn ensure_contact_with_name(&self, address: &str, name: Option<&str>) -> Result<Contact> {
        let mut contact = self.ensure_contact(address)?;
        if let Some(n) = name {
            if contact.display_name.as_deref() != Some(n) {
                contact.display_name = Some(n.to_string());
                self.data
                    .borrow_mut()
                    .contacts
                    .insert(address.to_string(), contact.clone());
                self.save()?;
            }
        }
        Ok(contact)
    }

    pub fn resolve_contact_id(&self, id: &str) -> Result<String> {
        self.data
            .borrow()
            .contacts
            .values()
            .find(|c| c.id == id)
            .map(|c| c.address.clone())
            .with_context(|| format!("No contact with ID {}", id))
    }

    pub fn contacts_by_domain(&self) -> HashMap<String, Vec<Contact>> {
        let mut map: HashMap<String, Vec<Contact>> = HashMap::new();
        for c in self.data.borrow().contacts.values() {
            map.entry(c.domain.clone()).or_default().push(c.clone());
        }
        // Sort each domain's list by numeric ID
        for list in map.values_mut() {
            list.sort_by(|a, b| {
                a.id.parse::<u64>()
                    .unwrap_or(0)
                    .cmp(&b.id.parse::<u64>().unwrap_or(0))
            });
        }
        map
    }

    pub fn all_contacts(&self) -> Vec<Contact> {
        let mut v: Vec<Contact> = self.data.borrow().contacts.values().cloned().collect();
        v.sort_by(|a, b| {
            a.id.parse::<u64>()
                .unwrap_or(0)
                .cmp(&b.id.parse::<u64>().unwrap_or(0))
        });
        v
    }

    // ── Message Operations ───────────────────────────────────────────────────

    pub fn record_sent(&self, to: &str, body: &str) -> Result<()> {
        // Auto-register recipient
        self.ensure_contact(to)?;
        let msg = Message {
            id: Uuid::new_v4().to_string(),
            from: String::new(), // filled by caller / config
            to: to.to_string(),
            body: body.to_string(),
            timestamp: Local::now(),
            read: true,
            starred: false,
            folder: Folder::Sent,
        };
        self.data.borrow_mut().messages.push(msg);
        self.save()
    }

    /// Called by the IMAP sync to store an incoming message.
    /// Auto-registers the sender as a contact.
    pub fn deliver(&self, from: &str, body: &str, display_name: Option<&str>) -> Result<()> {
        self.ensure_contact_with_name(from, display_name)?;
        let msg = Message {
            id: Uuid::new_v4().to_string(),
            from: from.to_string(),
            to: String::new(),
            body: body.to_string(),
            timestamp: Local::now(),
            read: false,
            starred: false,
            folder: Folder::Inbox,
        };
        self.data.borrow_mut().messages.push(msg);
        self.save()
    }

    pub fn messages_in(&self, folder: &Folder) -> Vec<(usize, Message)> {
        self.data
            .borrow()
            .messages
            .iter()
            .enumerate()
            .filter(|(_, m)| {
                if folder == &Folder::Starred {
                    m.starred
                } else {
                    &m.folder == folder
                }
            })
            .map(|(i, m)| (i, m.clone()))
            .collect()
    }

    pub fn get_message(&self, index: usize) -> Option<Message> {
        self.data.borrow().messages.get(index).cloned()
    }

    pub fn mark_read(&self, index: usize) -> Result<()> {
        if let Some(m) = self.data.borrow_mut().messages.get_mut(index) {
            m.read = true;
        }
        self.save()
    }

    pub fn toggle_star(&self, index: usize) -> Result<()> {
        if let Some(m) = self.data.borrow_mut().messages.get_mut(index) {
            m.starred = !m.starred;
        }
        self.save()
    }

    pub fn move_to_trash(&self, index: usize) -> Result<()> {
        if let Some(m) = self.data.borrow_mut().messages.get_mut(index) {
            m.folder = Folder::Trash;
        }
        self.save()
    }

    pub fn unread_count(&self) -> usize {
        self.data
            .borrow()
            .messages
            .iter()
            .filter(|m| !m.read && m.folder == Folder::Inbox)
            .count()
    }

    pub fn unread_in(&self, folder: &Folder) -> usize {
        self.data
            .borrow()
            .messages
            .iter()
            .filter(|m| !m.read && &m.folder == folder)
            .count()
    }
}

// ─── Helpers ──────────────────────────────────────────────────────────────────

fn split_address(addr: &str) -> (String, String) {
    if let Some(at) = addr.rfind('@') {
        (addr[..at].to_string(), addr[at + 1..].to_string())
    } else {
        (addr.to_string(), "unknown".to_string())
    }
}