/* * Copyright (c) 2023 Stalwart Labs Ltd. * * This file is part of Stalwart Mail Server. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of * the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * in the LICENSE file at the top-level directory of this distribution. * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * You can be released from the requirements of the AGPLv3 license by * purchasing a commercial license. Please contact licensing@stalw.art * for more details. */ use core::panic; use std::{fmt::Write, fs, path::PathBuf, sync::Arc}; use crate::smtp::{ inbound::{sign::TextConfigContext, TestMessage, TestQueueEvent}, session::{TestSession, VerifyResponse}, TestConfig, TestSMTP, }; use directory::core::config::ConfigDirectory; use smtp::{ config::{scripts::ConfigSieve, session::ConfigSession, ConfigContext}, core::{eval::V_REMOTE_IP, Session, SMTP}, scripts::ScriptResult, }; use store::{config::ConfigStore, Store}; use tokio::runtime::Handle; use utils::config::{if_block::IfBlock, Config}; const CONFIG: &str = r#" [storage] lookup = "sql" [store."sql"] type = "sqlite" path = "%PATH%/smtp_sieve.db" [store."sql".pool] max-connections = 10 min-connections = 0 idle-timeout = "5m" [store."local/invalid-ehlos"] type = "memory" format = "list" values = ["spammer.org", "spammer.net"] [session.data.pipe."test"] command = [ { if = "remote_ip = '10.0.0.123'", then = "'/bin/bash'" }, { else = false } ] arguments = "['%CFG_PATH%/pipe_me.sh', 'hello', 'world']" timeout = "10s" [sieve.trusted] from-name = "Sieve Daemon" from-addr = "sieve@foobar.org" return-path = "" hostname = "mx.foobar.org" sign = ["rsa"] [sieve.trusted.limits] redirects = 3 out-messages = 5 received-headers = 50 cpu = 10000 nested-includes = 5 duplicate-expiry = "7d" [sieve.trusted.scripts] "#; #[tokio::test] async fn sieve_scripts() { /*tracing::subscriber::set_global_default( tracing_subscriber::FmtSubscriber::builder() .with_max_level(tracing::Level::TRACE) .finish(), ) .unwrap();*/ // Add test scripts let mut config = CONFIG.to_string(); for entry in fs::read_dir( PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("resources") .join("smtp") .join("sieve"), ) .unwrap() { let entry = entry.unwrap(); writeln!( &mut config, "{} = \"file://{}\"", entry .file_name() .to_str() .unwrap() .split_once('.') .unwrap() .0, entry.path().to_str().unwrap() ) .unwrap(); } // Prepare config let mut core = SMTP::test(); let mut qr = core.init_test_queue("smtp_sieve_test"); let mut ctx = ConfigContext::new().parse_signatures(); let config = Config::new( &config .replace("%PATH%", qr._temp_dir.temp_dir.as_path().to_str().unwrap()) .replace( "%CFG_PATH%", PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("resources") .join("smtp") .join("pipe") .as_path() .to_str() .unwrap(), ), ) .unwrap(); ctx.stores = config.parse_stores().await.unwrap(); core.shared.lookup_stores = ctx.stores.lookup_stores.clone(); core.shared.directories = config .parse_directory(&ctx.stores, Store::default()) .await .unwrap() .directories; let pipes = config.parse_pipes(&[V_REMOTE_IP]).unwrap(); core.sieve = config.parse_sieve(&mut ctx).unwrap(); core.shared.signers = ctx.signers; core.shared.scripts = ctx.scripts.clone(); let config = &mut core.session.config; config.connect.script = IfBlock::new("stage_connect".to_string()); config.ehlo.script = IfBlock::new("stage_ehlo".to_string()); config.mail.script = IfBlock::new("stage_mail".to_string()); config.rcpt.script = IfBlock::new("stage_rcpt".to_string()); config.data.script = IfBlock::new("stage_data".to_string()); config.rcpt.relay = IfBlock::new(true); config.data.pipe_commands = pipes; let core = Arc::new(core); // Build session let mut session = Session::test(core.clone()); session.data.remote_ip_str = "10.0.0.88".parse().unwrap(); session.data.remote_ip = session.data.remote_ip_str.parse().unwrap(); assert!(!session.init_conn().await); // Run tests let span = tracing::info_span!("sieve_scripts"); for (name, script) in &ctx.scripts { if name.starts_with("stage_") || name.ends_with("_include") { continue; } let script = script.clone(); let params = session .build_script_parameters("data") .set_variable("from", "john.doe@example.org"); let handle = Handle::current(); let span = span.clone(); let core_ = core.clone(); match core .spawn_worker(move || core_.run_script_blocking(script, params, handle, span)) .await .unwrap() { ScriptResult::Accept { .. } => (), ScriptResult::Reject(message) => panic!("{}", message), err => { panic!("Unexpected script result {err:?}"); } } } // Test connect script session .response() .assert_contains("503 5.5.3 Your IP '10.0.0.88' is not welcomed here"); session.data.remote_ip_str = "10.0.0.5".parse().unwrap(); session.data.remote_ip = session.data.remote_ip_str.parse().unwrap(); assert!(session.init_conn().await); session .response() .assert_contains("220 mx.example.org at your service"); // Test EHLO script session .cmd( "EHLO spammer.org", "551 5.1.1 Your domain 'spammer.org' has been blacklisted", ) .await; session.cmd("EHLO foobar.net", "250").await; // Test MAIL-FROM script session .mail_from("spammer@domain.com", "450 4.1.1 Invalid address") .await; session .mail_from( "marketing@spam-domain.com", "503 5.5.3 Your address has been blocked", ) .await; session.mail_from("bill@foobar.org", "250").await; // Test RCPT-TO script session .rcpt_to( "jane@foobar.org", "422 4.2.2 You have been greylisted '10.0.0.5.bill@foobar.org.jane@foobar.org'.", ) .await; session.rcpt_to("jane@foobar.org", "250").await; // Expect a modified message session.data("test:multipart", "250").await; qr.expect_message() .await .read_lines(&qr) .await .assert_contains("X-Part-Number: 5") .assert_contains("THIS IS A PIECE OF HTML TEXT"); qr.assert_no_events(); // Expect rejection for bill@foobar.net session .send_message( "test@example.net", &["bill@foobar.net"], "test:multipart", "503 5.5.3 Bill cannot receive messages", ) .await; qr.assert_no_events(); qr.clear_queue(&core).await; // Expect message delivery plus a notification session .send_message( "test@example.net", &["john@foobar.net"], "test:multipart", "250", ) .await; qr.read_event().await.assert_reload(); qr.read_event().await.assert_reload(); let messages = qr.read_queued_messages().await; assert_eq!(messages.len(), 2); let mut messages = messages.into_iter().rev(); let notification = messages.next().unwrap(); assert_eq!(notification.return_path, ""); assert_eq!(notification.recipients.len(), 2); assert_eq!( notification.recipients.first().unwrap().address, "john@example.net" ); assert_eq!( notification.recipients.last().unwrap().address, "jane@example.org" ); notification .read_lines(&qr) .await .assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=example.com;") .assert_contains("From: \"Sieve Daemon\" ") .assert_contains("To: ") .assert_contains("Cc: ") .assert_contains("Subject: You have got mail") .assert_contains("One Two Three Four"); messages .next() .unwrap() .read_lines(&qr) .await .assert_contains("One Two Three Four") .assert_contains("multi-part message in MIME format") .assert_not_contains("X-Part-Number: 5") .assert_not_contains("THIS IS A PIECE OF HTML TEXT"); qr.assert_no_events(); qr.clear_queue(&core).await; // Expect a modified message delivery plus a notification session .send_message( "test@example.net", &["jane@foobar.net"], "test:multipart", "250", ) .await; qr.read_event().await.assert_reload(); qr.read_event().await.assert_reload(); let messages = qr.read_queued_messages().await; assert_eq!(messages.len(), 2); let mut messages = messages.into_iter().rev(); messages .next() .unwrap() .read_lines(&qr) .await .assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=example.com;") .assert_contains("From: \"Sieve Daemon\" ") .assert_contains("To: ") .assert_contains("Cc: ") .assert_contains("Subject: You have got mail") .assert_contains("One Two Three Four"); messages .next() .unwrap() .read_lines(&qr) .await .assert_contains("X-Part-Number: 5") .assert_contains("THIS IS A PIECE OF HTML TEXT") .assert_not_contains("X-My-Header: true"); qr.clear_queue(&core).await; // Expect a modified redirected message session .send_message( "test@example.net", &["thomas@foobar.gov"], "test:no_dkim", "250", ) .await; let redirect = qr.expect_message().await; assert_eq!(redirect.return_path, ""); assert_eq!(redirect.recipients.len(), 1); assert_eq!( redirect.recipients.first().unwrap().address, "redirect@here.email" ); redirect .read_lines(&qr) .await .assert_contains("From: no-reply@my.domain") .assert_contains("To: Suzie Q ") .assert_contains("Subject: Is dinner ready?") .assert_contains("Message-ID: <20030712040037.46341.5F8J@football.example.com>") .assert_not_contains("From: Joe SixPack "); qr.assert_no_events(); // Expect an intact redirected message session .send_message( "test@example.net", &["bob@foobar.gov"], "test:no_dkim", "250", ) .await; let redirect = qr.expect_message().await; assert_eq!(redirect.return_path, ""); assert_eq!(redirect.recipients.len(), 1); assert_eq!( redirect.recipients.first().unwrap().address, "redirect@somewhere.email" ); redirect .read_lines(&qr) .await .assert_not_contains("From: no-reply@my.domain") .assert_contains("To: Suzie Q ") .assert_contains("Subject: Is dinner ready?") .assert_contains("Message-ID: <20030712040037.46341.5F8J@football.example.com>") .assert_contains("From: Joe SixPack "); qr.assert_no_events(); // Test pipes session.data.remote_ip_str = "10.0.0.123".parse().unwrap(); session.data.remote_ip = session.data.remote_ip_str.parse().unwrap(); session .send_message( "test@example.net", &["pipe@foobar.com"], "test:no_dkim", "250", ) .await; qr.expect_message() .await .read_lines(&qr) .await .assert_contains("X-My-Header: true") .assert_contains("Authentication-Results"); qr.assert_no_events(); }