first commit
RustBox CI / rustbox (push) Waiting to run

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 15:24:59 +01:00
commit bc5fbd6ba9
180 files changed
+63750

No files matched your search

+366
View File
@@ -0,0 +1,366 @@
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";
/// `POST /api/messages/{folder}/{uid}/unsubscribe` — perform RFC 8058 one-click
/// unsubscribe when the message explicitly advertises List-Unsubscribe-Post.
pub async fn unsubscribe_message(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Path((folder, uid)): Path<(String, u32)>,
) -> Result<Response, AppError> {
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<String, AppError> {
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,
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<String> {
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(&current_name, &current_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(&current_name, &current_value)
}
fn angle_targets(value: &str) -> Vec<String> {
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<SocketAddr> =
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.1\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::<u16>().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: <mailto:list@example.com>,\r\n <https://example.com/unsub?id=1>\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()));
}
}