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 {
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 })
}
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))
}
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)
}
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)
}
pub async fn post_article_raw(
&mut self,
article: &PostArticle,
) -> Result<(), Box<dyn std::error::Error>> {
use tokio::io::{AsyncWriteExt, BufReader, BufWriter};
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);
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());
}
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()));
}
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?;
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(())
}
}