commit
bc5fbd6ba9
180 files changed
+63750
No files matched your search
@@ -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(¤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<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()));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user