Index: src/mail.rs ================================================================== --- src/mail.rs +++ src/mail.rs @@ -218,11 +218,11 @@ let mut files = vec![]; for chunk in &files_to_send { let data: Vec = chunk.decoded().to_vec(); let mut filename: Option = None; for header in chunk.headers().iter() - .filter(|header| header.raw_name() == "Content-Type") + .filter(|header| header.raw_name().eq_ignore_ascii_case("Content-Type")) { match header.value() { mail_parser::HeaderValue::ContentType(contenttype) => { if let Some(fname) = contenttype.attribute("name") { filename = Some(fname.to_owned()); ADDED tests/cli.rs Index: tests/cli.rs ================================================================== --- /dev/null +++ tests/cli.rs @@ -0,0 +1,209 @@ +//! The entry point is private to the binary, so test its public CLI and SMTP +//! behavior with isolated configuration directories and bounded subprocesses. +use std::{ + fs::{self, OpenOptions}, + io::{BufRead, BufReader, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, + path::PathBuf, + process::{Child, Command, Output, Stdio}, + sync::atomic::{AtomicUsize, Ordering}, + thread, + time::{Duration, Instant}, +}; +const DEADLINE: Duration = Duration::from_secs(5); +struct TestDir(PathBuf); +impl TestDir { + fn new () -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + loop { + let path = std::env::temp_dir().join(format!("smtp2tg-cli-{}-{}", + std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + match fs::create_dir(&path) { + Ok(()) => return Self(path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("Cannot create test directory: {error}"), + } + } + } + fn config (&self, name: &str, text: &str, mode: u32) { + let path = self.0.join(name); + let mut file = OpenOptions::new().create_new(true).write(true).mode(0o600) + .open(&path).unwrap(); + file.write_all(text.as_bytes()).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap(); + } + fn spawn (&self, args: &[&str]) -> Process { + Process(Some(Command::new(env!("CARGO_BIN_EXE_smtp2tg")) + .current_dir(&self.0).args(args) + .stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()) + .spawn().unwrap())) + } + fn run (&self, args: &[&str]) -> Output { + self.spawn(args).output() + } +} +impl Drop for TestDir { + fn drop (&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +struct Process(Option); +impl Process { + fn output (mut self) -> Output { + let deadline = Instant::now() + DEADLINE; + while self.0.as_mut().unwrap().try_wait().unwrap().is_none() { + assert!(Instant::now() < deadline, "CLI did not exit within {DEADLINE:?}"); + thread::sleep(Duration::from_millis(10)); + } + self.0.take().unwrap().wait_with_output().unwrap() + } + fn connect (&mut self, address: SocketAddr) -> TcpStream { + let deadline = Instant::now() + DEADLINE; + loop { + assert!(self.0.as_mut().unwrap().try_wait().unwrap().is_none(), + "SMTP server exited before accepting connections"); + if let Ok(stream) = TcpStream::connect_timeout(&address, Duration::from_millis(100)) { + stream.set_read_timeout(Some(DEADLINE)).unwrap(); + stream.set_write_timeout(Some(DEADLINE)).unwrap(); + return stream; + } + assert!(Instant::now() < deadline, "SMTP server did not start within {DEADLINE:?}"); + thread::sleep(Duration::from_millis(10)); + } + } +} +impl Drop for Process { + fn drop (&mut self) { + if let Some(child) = &mut self.0 { + let _ = child.kill(); + let _ = child.wait(); + } + } +} +fn assert_error (output: Output, expected: &str) { + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success(), "Expected failure, stderr: {stderr}"); + assert!(stderr.contains(expected), "Missing {expected:?} in stderr: {stderr}"); +} +#[test] +fn help_succeeds_without_a_configuration_file () { + let dir = TestDir::new(); + for flag in ["-h", "--help"] { + let output = dir.run(&[flag]); + assert!(output.status.success()); + let help = String::from_utf8(output.stdout).unwrap(); + for expected in ["SMTP-to-Telegram gateway", env!("CARGO_PKG_VERSION"), + "-c, --config", "[default: smtp2tg.toml]"] { + assert!(help.contains(expected), "Missing {expected:?} in help: {help}"); + } + } +} +#[test] +fn missing_default_and_explicit_configuration_paths_are_reported () { + let dir = TestDir::new(); + for (args, path) in [ + (vec![], "smtp2tg.toml"), + (vec!["-c", "missing file.toml"], "missing file.toml"), + (vec!["--config", "missing file.toml"], "missing file.toml"), + ] { + let output = dir.run(&args); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains(path), "Missing path in diagnostic: {stderr}"); + assert_error(output, "Configuration file not found"); + } +} +#[test] +fn invalid_arguments_fail_before_configuration_loading () { + let dir = TestDir::new(); + for args in [vec!["-c"], vec!["--config"], vec!["--unknown-option"]] { + let output = dir.run(&args); + assert_eq!(output.status.code(), Some(2), "arguments: {args:?}"); + assert!(!String::from_utf8_lossy(&output.stderr).contains("Configuration file not found")); + } +} +#[test] +fn insecure_permissions_are_rejected_before_parsing () { + // Each forbidden permission is independent: group read/write/execute, + // other read/write/execute, and owner execute. + for mode in [0o640, 0o620, 0o610, 0o604, 0o602, 0o601, 0o700] { + let dir = TestDir::new(); + dir.config("smtp2tg.toml", "this is not valid TOML", mode); + let output = dir.run(&[]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Required: 0600"), "mode {mode:o}: {stderr}"); + assert!(stderr.contains("chmod 600"), "mode {mode:o}: {stderr}"); + assert_error(output, "Configuration file permissions are insecure"); + } +} +#[test] +fn secure_malformed_config_reports_parse_context () { + let dir = TestDir::new(); + // A spaces-containing path must be passed intact, and the selected file + // must take precedence over an insecure default configuration. + dir.config("smtp2tg.toml", "ignored", 0o644); + dir.config("selected config.toml", "api_key = [", 0o600); + for flag in ["-c", "--config"] { + let output = dir.run(&[flag, "selected config.toml"]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("selected config.toml")); + assert!(stderr.contains("smtp2tg.toml.example")); + assert_error(output, "Failed to parse configuration file"); + } +} +#[test] +fn required_settings_remain_required_after_applying_defaults () { + for (config, expected) in [ + ("default = 0\n[recipients]\n", "api_key"), + ("api_key = 'test-api-key'\n[recipients]\n", "default"), + ("api_key = 'test-api-key'\ndefault = 0\n", "recipients"), + ] { + let dir = TestDir::new(); + dir.config("smtp2tg.toml", config, 0o600); + let output = dir.run(&[]); + assert_error(output, expected); + } +} +fn smtp_reply (stream: &mut BufReader) -> String { + let mut response = String::new(); + loop { + let mut line = String::new(); + assert!(stream.read_line(&mut line).unwrap() > 0, "SMTP connection closed: {response}"); + let more = line.as_bytes().get(3) == Some(&b'-'); + response.push_str(&line); + if !more { + return response; + } + } +} +#[test] +fn entry_point_serves_smtp_with_defaults_and_configuration_overrides () { + for (overrides, hostname, allowed, denied) in [ + ("", "smtp.2.tg", "localhost", "outside.invalid"), + ("hostname = 'custom.smtp'\ndomains = ['example.com']\nfields = []\n", + "custom.smtp", "example.com", "localhost"), + ] { + let dir = TestDir::new(); + let reservation = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = reservation.local_addr().unwrap(); + dir.config("smtp2tg.toml", &format!( + "api_key = 'test-api-key'\ndefault = 0\nlisten_on = '{address}'\n{overrides}[recipients]\n"), 0o600); + drop(reservation); + let mut process = dir.spawn(&[]); + let mut stream = BufReader::new(process.connect(address)); + let banner = smtp_reply(&mut stream); + assert!(banner.starts_with("220 "), "{banner}"); + assert!(banner.contains(hostname), "{banner}"); + for (command, code) in [ + ("EHLO test.local\r\n".into(), "250"), + ("MAIL FROM:\r\n".into(), "250"), + (format!("RCPT TO:\r\n"), "250"), + (format!("RCPT TO:\r\n"), "550"), + ("QUIT\r\n".into(), "221"), + ] { + stream.get_mut().write_all(command.as_bytes()).unwrap(); + let reply = smtp_reply(&mut stream); + assert!(reply.starts_with(code), "command {command:?}: {reply}"); + } + } +} ADDED tests/mail_relay.rs Index: tests/mail_relay.rs ================================================================== --- /dev/null +++ tests/mail_relay.rs @@ -0,0 +1,206 @@ +//! Exercise the production mail handler with a recording transport. Including +//! the module here provides a test seam for its private Telegram dependency +//! without changing production visibility or making HTTP requests. +#[path = "../src/mail.rs"] +mod mail; +use smtp2tg::utils; +use std::io::Cursor; +use mailin_embedded::{Handler, response::{INTERNAL_ERROR, OK}}; +use stacked_errors::{Result, StackableErr}; +#[derive(Debug, PartialEq)] +enum Delivery { + Debug(String), + Text(String), + Files(Vec<(String, Vec)>, String), +} +mod telegram { + use super::{Delivery, utils::Attachment}; + use std::{cell::RefCell, collections::HashMap}; + use stacked_errors::{Result, StackableErr}; + use tgbot::types::ChatPeerId; + thread_local! { + // Handler::data_end drives its future on the calling test thread. + static DELIVERIES: RefCell> = const { RefCell::new(Vec::new()) }; + } + pub fn take_deliveries () -> Vec { + DELIVERIES.with(|items| std::mem::take(&mut *items.borrow_mut())) + } + #[derive(Debug)] + pub struct TelegramTransport { + pub default: ChatPeerId, + recipients: HashMap, + } + impl TelegramTransport { + pub fn new (_api_key: String, recipients: HashMap, settings: &config::Config) -> Result { + Ok(Self { + default: settings.get_int("default").stack()?.into(), + recipients: recipients.into_iter().map(|(name, id)| (name, id.into())).collect(), + }) + } + pub fn get (&self, name: &str) -> Result<&ChatPeerId> { + self.recipients.get(&name.to_lowercase()).context("Unknown test recipient") + } + pub async fn debug (&self, msg: &str) -> Result<()> { + DELIVERIES.with(|items| items.borrow_mut().push(Delivery::Debug(msg.into()))); + Ok(()) + } + pub async fn send (&self, _to: &ChatPeerId, msg: &str) -> Result<()> { + DELIVERIES.with(|items| items.borrow_mut().push(Delivery::Text(msg.into()))); + Ok(()) + } + pub async fn sendgroup (&self, _to: &ChatPeerId, media: Vec, msg: &str) -> Result<()> { + let files = media.into_iter().map(|file| (file.name, file.data.into_inner())).collect(); + DELIVERIES.with(|items| items.borrow_mut().push(Delivery::Files(files, msg.into()))); + Ok(()) + } + } +} +fn server () -> Result { + telegram::take_deliveries(); + let settings = config::Config::builder() + .add_source(config::File::from_str(r#" + api_key = "test-api-key" + api_gateway = "https://api.telegram.org" + default = 0 + fields = [] + domains = ["example.com"] + [recipients] + "recipient@example.com" = 1 + "#, config::FileFormat::Toml)) + .build().stack()?; + mail::MailServer::new(settings) +} +fn relay (message: &str) -> Result> { + let mut server = server()?; + assert_eq!(server.data_start("example.com", "sender@example.com", true, + &["recipient@example.com".into()]), OK); + server.data(message.as_bytes()).stack()?; + assert_eq!(server.data_end(), OK); + Ok(telegram::take_deliveries()) +} +fn caption (body: &str) -> String { + format!("
\n
{body}
") +} +fn multipart (kind: &str, parts: &[&str]) -> String { + format!("MIME-Version: 1.0\r\nContent-Type: multipart/{kind}; boundary=parts\r\n\r\n--parts\r\n{}\r\n--parts--\r\n", + parts.join("\r\n--parts\r\n")) +} +const TEXT: &str = "Content-Type: text/plain; charset=utf-8\r\n\r\nPlain body"; +const HTML: &str = "Content-Type: text/html; charset=utf-8\r\n\r\n

HTML body

"; +#[test] +fn short_text_is_inlined_and_normalized () -> Result<()> { + let deliveries = relay("Content-Type: text/plain\r\n\r\nFirst & \r\nThird")?; + assert_eq!(deliveries, vec![ + Delivery::Text(caption("First & <second>\nThird")), + ]); + Ok(()) +} +#[test] +fn alternative_html_is_not_sent_as_a_duplicate_attachment () -> Result<()> { + let deliveries = relay(&multipart("alternative", &[TEXT, HTML]))?; + assert_eq!(deliveries, vec![Delivery::Text(caption("Plain body"))]); + Ok(()) +} +#[test] +fn html_only_message_uses_the_parsers_text_conversion () -> Result<()> { + assert_eq!(relay(HTML)?, vec![ + Delivery::Text(caption("HTML body\n")), + ]); + Ok(()) +} +#[test] +fn remaining_text_parts_are_sent_once_in_order () -> Result<()> { + let second = "Content-Type: text/plain\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nSecond=20body"; + let third = "Content-Type: text/plain\r\n\r\nThird body"; + assert_eq!(relay(&multipart("mixed", &[TEXT, second, third]))?, vec![ + Delivery::Files(vec![ + ("Attachment.txt".into(), b"Second body".to_vec()), + ("Attachment.txt".into(), b"Third body".to_vec()), + ], caption("Plain body")), + ]); + Ok(()) +} +#[test] +fn text_at_the_message_limit_is_sent_as_a_decoded_attachment () -> Result<()> { + // The existing contract requires the complete escaped message to be <4096 + // bytes. Exercise both sides so the first text iterator item cannot be lost. + let capacity = 4096 - caption("").len(); + for size in [capacity - 1, capacity] { + let body = "x".repeat(size); + let message = format!("Content-Type: text/plain\r\n\r\n{body}"); + let deliveries = relay(&message)?; + assert_eq!(deliveries.len(), 1); + let expected = if size < capacity { + Delivery::Text(caption(&body)) + } else { + Delivery::Files(vec![("Attachment.txt".into(), body.into_bytes())], caption("")) + }; + assert_eq!(deliveries[0], expected, "body size: {size}"); + } + Ok(()) +} +#[test] +fn attachment_payloads_are_transfer_decoded_and_keep_content_type_names () -> Result<()> { + let binary = "Content-Type: application/octet-stream; name=\"payload.bin\"\r\nContent-Disposition: attachment\r\nContent-Transfer-Encoding: base64\r\n\r\nAAEC/w=="; + let quoted = "Content-Type: application/octet-stream; name=\"notes.txt\"\r\nContent-Disposition: attachment\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\ncaf=C3=A9=0Asecond=3Dline"; + assert_eq!(relay(&multipart("mixed", &[TEXT, binary, quoted]))?, vec![ + Delivery::Files(vec![ + ("payload.bin".into(), vec![0, 1, 2, 255]), + ("notes.txt".into(), "café\nsecond=line".as_bytes().to_vec()), + ], caption("Plain body")), + ]); + Ok(()) +} +#[test] +fn unnamed_attachment_uses_the_fallback_filename () -> Result<()> { + let attachment = "Content-Type: application/octet-stream\r\nContent-Disposition: attachment\r\nContent-Transfer-Encoding: base64\r\n\r\naGVsbG8="; + assert_eq!(relay(&multipart("mixed", &[attachment]))?, vec![ + Delivery::Files(vec![("Attachment.txt".into(), b"hello".to_vec())], caption("")), + ]); + Ok(()) +} +#[test] +fn attachment_filename_is_preserved_for_case_insensitive_mime_headers () -> Result<()> { + // MIME field names are case insensitive. The old parser's Header::name() + // returned canonical names; raw_name() now preserves their original case. + for header in ["Content-Type", "content-type", "CONTENT-TYPE", "cOnTeNt-TyPe"] { + let attachment = format!("{header}: application/octet-stream; name=\"report.bin\"\r\nContent-Disposition: attachment\r\nContent-Transfer-Encoding: base64\r\n\r\nAAEC/w=="); + assert_eq!(relay(&multipart("mixed", &[&attachment]))?, vec![ + Delivery::Files(vec![("report.bin".into(), vec![0, 1, 2, 255])], caption("")), + ], "MIME header: {header}"); + } + Ok(()) +} +#[test] +fn nested_alternatives_and_files_use_separate_iterators () -> Result<()> { + let alternative = multipart("alternative", &[TEXT, HTML]).replace("parts", "inner"); + let extra = "Content-Type: text/plain\r\n\r\nExtra body"; + let attachment = "Content-Type: application/octet-stream; name=\"file.bin\"\r\nContent-Disposition: attachment\r\nContent-Transfer-Encoding: base64\r\n\r\nAAEC/w=="; + assert_eq!(relay(&multipart("mixed", &[&alternative, extra, attachment]))?, vec![ + Delivery::Files(vec![ + ("Attachment.txt".into(), b"Extra body".to_vec()), + ("file.bin".into(), vec![0, 1, 2, 255]), + ], caption("Plain body")), + ]); + Ok(()) +} +#[test] +fn empty_text_body_does_not_create_a_spurious_attachment () -> Result<()> { + assert_eq!(relay("Content-Type: text/plain\r\n\r\n")?, vec![ + Delivery::Text(caption("")), + ]); + Ok(()) +} +#[test] +fn forbidden_closing_tag_stops_delivery () -> Result<()> { + let mut server = server()?; + server.data_start("example.com", "sender@example.com", false, + &["recipient@example.com".into()]); + server.data(b"Content-Type: text/plain\r\n\r\nUnsafe body").stack()?; + assert_eq!(server.data_end(), INTERNAL_ERROR); + let deliveries = telegram::take_deliveries(); + assert_eq!(deliveries.len(), 1); + assert!(matches!(&deliveries[0], Delivery::Debug(message) + if message.starts_with("Sending emails failed:"))); + Ok(()) +}