usenet_reborn 0.2.2

Terminal-based Usenet NNTP client written in Rust with ratatui/crossterm.
use chrono::{DateTime, Local, TimeZone};
use rek2_nntp::utils;
use rek2_nntp::{
    authenticate, body, fetch_xover_range, group::group as raw_group, head, post::Article as PostArticle,
    AuthenticatedConnection,
};
use rfc2047_decoder::decode;
use std::error::Error;

pub struct NntpClient {
    pub connection: AuthenticatedConnection,
}

impl NntpClient {
    /// Connects to the NNTP server using TLS.
    pub async fn connect(
        server: &str,
        username: &str,
        password: &str,
    ) -> Result<Self, Box<dyn Error>> {
        let connection = authenticate(server, username, password).await?;
        Ok(Self { connection })
    }

    /// Helper: runs the `GROUP` command and parses out the low/high IDs.
    pub async fn group_bounds(&mut self, group: &str) -> Result<(u32, u32), Box<dyn Error>> {
        let reply = raw_group(&mut self.connection, group).await?;
        let parts: Vec<&str> = reply.split_whitespace().collect();
        if parts.len() < 4 {
            return Err(format!("Unexpected GROUP response: {}", reply).into());
        }
        let low: u32 = parts[2].parse()?;
        let high: u32 = parts[3].parse()?;
        Ok((low, high))
    }

    /// Fetches the full article body via the `BODY` command.
    pub async fn fetch_article_body(&mut self, article_id: &str) -> Result<String, Box<dyn Error>> {
        let full_body = body(&mut self.connection, article_id).await?;
        Ok(full_body)
    }

    /// Fetches article headers via the `HEAD` command.
    pub async fn fetch_article_headers(&mut self, article_id: &str) -> Result<String, Box<dyn Error>> {
        let headers = head(&mut self.connection, article_id).await?;
        Ok(headers)
    }

    pub async fn fetch_new_subjects(
        &mut self,
        group: &str,
        last_seen: u32,
    ) -> Result<
        Vec<(
            String,
            String,
            String,
            String,
            Option<String>,
            Option<String>,
        )>,
        Box<dyn Error>,
    > {
        let (low, high) = self.group_bounds(group).await?;
        let start = (last_seen + 1).max(low);
        if start > high {
            return Ok(Vec::new());
        }

        let raw_headers =
            fetch_xover_range(&mut self.connection, group, Some((start, high))).await?;

        let list = raw_headers
            .into_iter()
            .map(|entry| {
                let id_str = entry.article_id.to_string();

                let subject =
                    decode(entry.subject.as_bytes()).unwrap_or_else(|_| entry.subject.clone());

                let sender_name = entry
                    .from
                    .split('<')
                    .next()
                    .unwrap_or("")
                    .trim()
                    .to_string();
                let sender = decode(sender_name.as_bytes()).unwrap_or_else(|_| sender_name.clone());

                let parsed_date = DateTime::parse_from_rfc2822(&entry.date)
                    .map(|dt| dt.with_timezone(&Local))
                    .unwrap_or_else(|_| Local.timestamp_opt(0, 0).unwrap());

                let date = parsed_date.format("%a, %d %b %Y %H:%M").to_string();

                (
                    id_str,
                    subject,
                    sender,
                    date,
                    entry.message_id.clone(),
                    entry.references.clone(),
                )
            })
            .collect();

        Ok(list)
    }

    /// Posts an article using the raw `POST` command (no GROUP selection),
    /// allowing multiple target newsgroups as per RFC 5536.
    /// I will eventually remove the older function to use this one for all
    pub async fn post_article_raw(
        &mut self,
        article: &PostArticle,
    ) -> Result<(), Box<dyn std::error::Error>> {
        use tokio::io::{AsyncWriteExt, BufReader, BufWriter};

        // Split the TLS stream for independent read/write
        let (read_half, write_half) = tokio::io::split(&mut self.connection.tls_stream);
        let mut reader = BufReader::new(read_half);
        let mut writer = BufWriter::new(write_half);

        // 1. Send the POST command (without selecting a group first)
        writer.write_all(b"POST\r\n").await?;
        writer.flush().await?;
        let response = utils::wait_for_response(&mut reader, &["340"], 5, 3).await?;
        if !response.starts_with("340") {
            return Err("NNTP server rejected POST command".into());
        }

        // 2. Compose article headers including all target newsgroups
        let mut headers = format!(
            "From: {}\r\nNewsgroups: {}\r\nSubject: {}",
            article.from.trim(),
            article.newsgroups.trim(),
            article.subject.trim()
        );
        if let Some(ref msg_id) = article.message_id {
            headers.push_str(&format!("\r\nMessage-ID: {}", msg_id.trim()));
        }
        if let Some(ref refs) = article.references {
            headers.push_str(&format!("\r\nReferences: {}", refs.trim()));
        }

        // 3. Send headers, a blank line, then body, ending with <CRLF>.<CRLF>
        let article_data = format!("{}\r\n\r\n{}\r\n.\r\n", headers, article.body.trim());
        writer.write_all(article_data.as_bytes()).await?;
        writer.flush().await?;

        // 4. Await final server reply (expect "240 <OK>" if posted successfully)
        let final_response = utils::wait_for_response(&mut reader, &["240", "441"], 5, 3).await?;
        if !final_response.starts_with("240") {
            return Err(format!("Posting failed: {}", final_response).into());
        }
        Ok(())
    }
}