use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; use std::time::Duration; use axum::extract::Path; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::time::timeout; use url::Url; use crate::auth::session::SessionState; use crate::config::AppConfig; use crate::db; use crate::error::AppError; use crate::imap::client::{ImapClient, ImapCredentials}; const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); const MAX_RESPONSE_BYTES: usize = 8192; const ONE_CLICK_BODY: &str = "List-Unsubscribe=One-Click"; pub async fn unsubscribe_message( Extension(session): Extension, Extension(config): Extension>, Extension(imap_client): Extension>, Path((folder, uid)): Path<(String, u32)>, ) -> Result { let raw_headers = load_raw_headers(&session, &config, imap_client.as_ref(), &folder, uid).await?; let list_unsubscribe = header_value(&raw_headers, "List-Unsubscribe").ok_or_else(|| { AppError::BadRequest("Message does not provide List-Unsubscribe".to_string()) })?; let list_unsubscribe_post = header_value(&raw_headers, "List-Unsubscribe-Post").ok_or_else(|| { AppError::BadRequest("Message does not advertise one-click unsubscribe".to_string()) })?; if !list_unsubscribe_post .split(',') .map(str::trim) .any(|value| value.eq_ignore_ascii_case("List-Unsubscribe=One-Click")) { return Err(AppError::BadRequest( "Message does not advertise RFC 8058 one-click unsubscribe".to_string(), )); } let target = angle_targets(&list_unsubscribe) .into_iter() .find(|value| value.to_ascii_lowercase().starts_with("https://")) .ok_or_else(|| { AppError::BadRequest("One-click unsubscribe requires an HTTPS target".to_string()) })?; post_one_click(&target).await?; Ok(Json(serde_json::json!({ "status": "unsubscribed", "method": "one-click" })) .into_response()) } async fn load_raw_headers( session: &SessionState, config: &AppConfig, imap_client: &dyn ImapClient, folder: &str, uid: u32, ) -> Result { let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; if let Some(raw_headers) = db::messages::get_cached_body(&conn, folder, uid) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))? .and_then(|body| body.raw_headers) .filter(|headers| !headers.trim().is_empty()) { return Ok(raw_headers); } let creds = ImapCredentials { host: session.imap_host.clone(), port: session.imap_port, tls: session.imap_tls, tls_verify: session.imap_tls_verify, email: session.email.clone(), password: session.password.clone(), }; let body = imap_client .fetch_body(&creds, folder, uid) .await .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; Ok(body.raw_headers) } fn header_value(raw_headers: &str, header_name: &str) -> Option { let mut current_name = String::new(); let mut current_value = String::new(); let wanted = header_name.to_ascii_lowercase(); let finish = |name: &str, value: &str| { if name.eq_ignore_ascii_case(&wanted) { Some(value.trim().to_string()) } else { None } }; for line in raw_headers.lines() { if line.starts_with(' ') || line.starts_with('\t') { if !current_name.is_empty() { current_value.push(' '); current_value.push_str(line.trim()); } continue; } if let Some(value) = finish(¤t_name, ¤t_value) { return Some(value); } let Some((name, value)) = line.split_once(':') else { current_name.clear(); current_value.clear(); continue; }; current_name.clear(); current_name.push_str(name.trim()); current_value.clear(); current_value.push_str(value.trim()); } finish(¤t_name, ¤t_value) } fn angle_targets(value: &str) -> Vec { let mut targets = Vec::new(); let mut rest = value; while let Some(start) = rest.find('<') { rest = &rest[start + 1..]; let Some(end) = rest.find('>') else { break }; let target = rest[..end].trim(); if !target.is_empty() { targets.push(target.to_string()); } rest = &rest[end + 1..]; } targets } async fn post_one_click(target: &str) -> Result<(), AppError> { let url = Url::parse(target) .map_err(|_| AppError::BadRequest("Invalid unsubscribe URL".to_string()))?; if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() { return Err(AppError::BadRequest( "One-click unsubscribe target must be a credential-free HTTPS URL".to_string(), )); } let host = url .host_str() .ok_or_else(|| AppError::BadRequest("Unsubscribe URL has no host".to_string()))?; let port = url.port_or_known_default().unwrap_or(443); let addresses: Vec = timeout(REQUEST_TIMEOUT, tokio::net::lookup_host((host, port))) .await .map_err(|_| { AppError::ServiceUnavailable("Unsubscribe DNS lookup timed out".to_string()) })? .map_err(|e| { AppError::ServiceUnavailable(format!("Unsubscribe DNS lookup failed: {e}")) })? .filter(|addr| is_public_ip(addr.ip())) .collect(); if addresses.is_empty() { return Err(AppError::BadRequest( "Unsubscribe target resolves only to non-public addresses".to_string(), )); } let mut last_error = None; for address in addresses { match post_one_click_to_address(&url, host, port, address).await { Ok(()) => return Ok(()), Err(error) => last_error = Some(error), } } Err(AppError::ServiceUnavailable(last_error.unwrap_or_else( || "Unsubscribe request failed".to_string(), ))) } async fn post_one_click_to_address( url: &Url, host: &str, port: u16, address: SocketAddr, ) -> Result<(), String> { let tcp = timeout(REQUEST_TIMEOUT, TcpStream::connect(address)) .await .map_err(|_| "Unsubscribe connection timed out".to_string())? .map_err(|e| format!("Unsubscribe connection failed: {e}"))?; let tls = async_native_tls::TlsConnector::new(); let mut stream = timeout(REQUEST_TIMEOUT, tls.connect(host, tcp)) .await .map_err(|_| "Unsubscribe TLS handshake timed out".to_string())? .map_err(|e| format!("Unsubscribe TLS handshake failed: {e}"))?; let mut path = url.path().to_string(); if path.is_empty() { path.push('/'); } if let Some(query) = url.query() { path.push('?'); path.push_str(query); } let host_header = if host.contains(':') { if port == 443 { format!("[{host}]") } else { format!("[{host}]:{port}") } } else if port == 443 { host.to_string() } else { format!("{host}:{port}") }; let request = format!( "POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.5\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}", ONE_CLICK_BODY.len() ); timeout(REQUEST_TIMEOUT, stream.write_all(request.as_bytes())) .await .map_err(|_| "Unsubscribe request timed out".to_string())? .map_err(|e| format!("Unsubscribe request failed: {e}"))?; timeout(REQUEST_TIMEOUT, stream.flush()) .await .map_err(|_| "Unsubscribe request timed out".to_string())? .map_err(|e| format!("Unsubscribe request failed: {e}"))?; let mut response = Vec::with_capacity(1024); let mut chunk = [0_u8; 1024]; while response.len() < MAX_RESPONSE_BYTES { let read = timeout(REQUEST_TIMEOUT, stream.read(&mut chunk)) .await .map_err(|_| "Unsubscribe response timed out".to_string())? .map_err(|e| format!("Failed to read unsubscribe response: {e}"))?; if read == 0 { break; } let remaining = MAX_RESPONSE_BYTES - response.len(); response.extend_from_slice(&chunk[..read.min(remaining)]); if response.windows(4).any(|window| window == b"\r\n\r\n") { break; } } let first_line = String::from_utf8_lossy(&response) .lines() .next() .unwrap_or_default() .trim_end_matches('\r') .to_string(); let status = first_line .split_whitespace() .nth(1) .and_then(|value| value.parse::().ok()) .ok_or_else(|| "Unsubscribe endpoint returned an invalid HTTP response".to_string())?; if !(200..300).contains(&status) { return Err(format!("Unsubscribe endpoint returned HTTP {status}")); } Ok(()) } fn is_public_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(ip) => is_public_ipv4(ip), IpAddr::V6(ip) => is_public_ipv6(ip), } } fn is_public_ipv4(ip: Ipv4Addr) -> bool { let [a, b, c, d] = ip.octets(); if ip.is_private() || ip.is_loopback() || ip.is_link_local() || ip.is_multicast() || ip.is_broadcast() || ip.is_unspecified() { return false; } !matches!( (a, b, c, d), (0, _, _, _) | (100, 64..=127, _, _) | (192, 0, 0, _) | (192, 0, 2, _) | (198, 18..=19, _, _) | (198, 51, 100, _) | (203, 0, 113, _) | (240..=255, _, _, _) ) } fn is_public_ipv6(ip: Ipv6Addr) -> bool { if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() || ip.is_unique_local() || ip.is_unicast_link_local() { return false; } let segments = ip.segments(); if segments[0] == 0x2001 && segments[1] == 0x0db8 { return false; } if let Some(v4) = ip.to_ipv4_mapped() { return is_public_ipv4(v4); } true } #[cfg(test)] mod tests { use super::*; #[test] fn parses_folded_list_unsubscribe_header() { let raw = "List-Unsubscribe: ,\r\n \r\nList-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n"; let value = header_value(raw, "list-unsubscribe").unwrap(); assert!(value.contains("https://example.com/unsub?id=1")); assert_eq!(angle_targets(&value).len(), 2); assert_eq!( header_value(raw, "List-Unsubscribe-Post").as_deref(), Some("List-Unsubscribe=One-Click") ); } #[test] fn rejects_private_and_documentation_addresses() { for value in [ "127.0.0.1", "10.0.0.1", "169.254.1.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "::1", "fd00::1", "2001:db8::1", ] { assert!( !is_public_ip(value.parse().unwrap()), "{value} should not be public" ); } assert!(is_public_ip("1.1.1.1".parse().unwrap())); assert!(is_public_ip("2606:4700:4700::1111".parse().unwrap())); } }