Index: Cargo.lock ================================================================== --- Cargo.lock +++ Cargo.lock @@ -8,10 +8,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] [[package]] name = "async-channel" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -291,19 +341,65 @@ "cfg-if", "cpufeatures", "rand_core", ] +[[package]] +name = "clap" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" @@ -621,10 +717,16 @@ "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" @@ -640,13 +742,13 @@ "windows-link", ] [[package]] name = "html-escape" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e" +checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5" [[package]] name = "http" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -866,10 +968,16 @@ name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" @@ -942,16 +1050,10 @@ "cfg-if", "futures-util", "wasm-bindgen", ] -[[package]] -name = "just-getopt" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97185bbe8f4edbaf6a3acc4f3f723358488d2da28e8f12a83529ddc6268f9c30" - [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" @@ -991,11 +1093,10 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47785d444be4d32c1709171c6219a90f667c0ad0ffe68b4b179e794f31f4f9e8" dependencies = [ "hashify", - "serde", ] [[package]] name = "mailin" version = "0.6.5" @@ -1077,10 +1178,16 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" @@ -1669,18 +1776,17 @@ "futures-lite", ] [[package]] name = "smtp2tg" -version = "0.6.6" +version = "0.6.7" dependencies = [ "async-compat", + "clap", "config", "hostname", "html-escape", - "just-getopt", - "lazy_static", "mail-parser", "mailin-embedded", "regex", "smol", "stacked_errors", @@ -2021,10 +2127,16 @@ name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" Index: Cargo.toml ================================================================== --- Cargo.toml +++ Cargo.toml @@ -1,8 +1,8 @@ [package] name = "smtp2tg" -version = "0.6.6" +version = "0.6.7" authors = [ "arcade@b1t.name" ] edition = "2024" license = "0BSD" repository = "http://fs.b1t.name/smtp2tg" @@ -9,13 +9,12 @@ [dependencies] async-compat = "0.2.5" config = { version = "0.15", default-features = false, features = [ "toml" ] } hostname = "0.4.1" html-escape = "0.2.13" -just-getopt = "2.0.0" -lazy_static = "1.5.0" -mail-parser = { version = "0.11", features = ["serde"] } +clap = { version = "4.6", features = [ "derive" ] } +mail-parser = "0.11" mailin-embedded = "0.8" regex = "1.11.1" smol = "2.0.2" stacked_errors = "0.7.1" tgbot = "0.46" DELETED README Index: README ================================================================== --- README +++ /dev/null @@ -1,20 +0,0 @@ -First of all make sure to never listen on public IPs and always put sockets in -directories not acessible by just about everyone on your system. Though -listening on 127.0.0.1:XXXX is equivalent to creating a world-writable socket. - -To catch bounces (so they wouldn't stuck in upper mail server) make sure sender -envelope address is real as required by mail library (actually not sure whether -this applies to mailin). For example Postfix has to be tweaked like this: - -$config_directory/main.cf: - smtp_generic_maps = hash:$config_directory/generic - -$config_directory/generic: - "" postmaster@example.com - <> postmaster@example.com - -Actually not sure which one works... - -PS: If you are reading this on GitHub - it's a RO mirror from original repo: - -http://fs.b1t.name/smtp2tg/timeline ADDED README.md Index: README.md ================================================================== --- /dev/null +++ README.md @@ -0,0 +1,101 @@ +# smtp2tg + +SMTP-to-Telegram gateway. Receives emails via SMTP and forwards them to Telegram chats. + +--- + +## Installation + +### From source +```bash +git clone https://github.com/kworr/smtp2tg +cd smtp2tg +cargo build --release +``` + +## Configuration + +1. Create `smtp2tg.toml` (see `smtp2tg.toml.example` for reference). +2. Get a Telegram Bot Token from [@BotFather](https://t.me/BotFather). +3. Get your chat ID (use [@getidsbot](https://t.me/getidsbot) or debug mode in Telegram client). + +### Example configuration +```toml +api_key = "replace-with-your-telegram-bot-token" +api_gateway = "https://api.telegram.org" +listen_on = "127.0.0.1:1025" +fields = ["date", "from", "subject"] +domains = ["example.com", "localhost"] + +default = 0 + +[recipients] +"admin@example.com" = 12345678 +"alerts@example.com" = -10012345678 +``` + +All recipients are internally lowercased, so `UserName` equals `username`. + +--- + +## Usage + +### Run +```bash +./smtp2tg -c /path/to/smtp2tg.toml +``` + +### CLI arguments + +| Argument | Description | Example | +|---------------|---------------------------|-----------------------| +| `-h`, `--help` | Show help | `smtp2tg --help` | +| `-c`, `--config` | Path to config file | `smtp2tg -c config.toml` | + +--- + +## Security + +### Important +- **Recommended usage**: Run on `127.0.0.1` (localhost) only. +- If exposing to a network: + - **Restrict port access** via firewall. + - **Use TLS** (e.g., via `stunnel` or `nginx`). + - **Implement authentication** (this software does not provide it). + +### Config file permissions +Set file permissions to `0600` (owner read/write only): +```bash +chmod 600 smtp2tg.toml +``` + +--- +## How it works +1. A client (e.g., Postfix) sends an email to `listen_on` (e.g., `127.0.0.1:1025`). +2. smtp2tg parses the email and converts it to a Telegram message. +3. The message is sent to the specified chat (or `default` if address is unknown). + +### Example: Email → Telegram +**Incoming email:** +```text +From: user@example.com +To: admin@example.com +Subject: Test + +Hello, world! +``` + +**Telegram message:** +```html +
+Subject:Test +From:user@example.com +Date:Mon, 01 Jan 2024 12:00:00 +0000 +
+
Hello, world!
+``` + +--- +## Links +- **Original repository**: [http://fs.b1t.name/smtp2tg](http://fs.b1t.name/smtp2tg) +- **GitHub Mirror**: [https://github.com/kworr/smtp2tg](https://github.com/kworr/smtp2tg) Index: src/lib.rs ================================================================== --- src/lib.rs +++ src/lib.rs @@ -1,61 +1,57 @@ +//! SMTP-to-Telegram gateway main library. +//! +//! This module provides the core functionality for receiving emails via SMTP +//! and forwarding them to Telegram chats. +//! +//! As we are not actually exporting this lib there would be no local Error's +//! for now, everything will be just .stack()?'ed and propagated like in real +//! bin The lib here is just to separate all tests from main code into tests/ + pub mod mail; mod telegram; pub mod utils; -// As we are not actually exporting this lib there would be no local Error's for -// now, everything will be just .stack()?'ed and propagated like in real bin -// The lib here is just to separate all tests from main code into tests/ - use crate::mail::MailServer; use std::{ io::Cursor, os::unix::fs::PermissionsExt, path::Path, }; -use just_getopt::{ - OptFlags, - OptSpecs, - OptValue, -}; +use clap::Parser; use smol::{ fs::metadata, }; use stacked_errors::{ Result, StackableErr, bail, }; -/// Actual main function running async with Error propagation support +/// SMTP-to-Telegram gateway +#[derive(Parser, Debug)] +#[command(name = "smtp2tg")] +#[command(about = format!("SMTP-to-Telegram gateway v{}, (C) 2024 - 2026", env!("CARGO_PKG_VERSION")), long_about = None)] +struct Args { + /// Set configuration file location + #[arg(short, long, default_value = "smtp2tg.toml")] + config: String, +} + +/// Main asynchronous entry point for the application. +/// +/// Parses command-line arguments, loads configuration, and starts the SMTP +/// server. +/// +/// # Errors +/// Returns an error if configuration is invalid, files are inaccessible, or +/// server fails to start. pub async fn async_main () -> Result<()> { - let specs = OptSpecs::new() - .option("help", "h", OptValue::None) - .option("help", "help", OptValue::None) - .option("config", "c", OptValue::Required) - .option("config", "config", OptValue::Required) - .flag(OptFlags::OptionsEverywhere); - let mut args = std::env::args(); - args.next(); - let parsed = specs.getopt(args); - for u in &parsed.unknown { - println!("Unknown option: {u}"); - } - if !(parsed.unknown.is_empty()) || parsed.options_first("help").is_some() { - println!("SMTP2TG v{}, (C) 2024 - 2026\n\n\ - \t-h|--help\tDisplay this help\n\ - \t-c|--config …\tSet configuration file location.", - env!("CARGO_PKG_VERSION")); - return Ok(()); - }; - let config_file = Path::new(if let Some(path) = parsed.options_value_last("config") { - &path[..] - } else { - "smtp2tg.toml" - }); + let args = Args::parse(); + let config_file = Path::new(&args.config); if !config_file.exists() { bail!("can't read configuration from {config_file:?}"); }; { let meta = metadata(config_file).await.stack()?; @@ -67,14 +63,13 @@ let settings: config::Config = config::Config::builder() .set_default("api_gateway", "https://api.telegram.org").stack()? .set_default("fields", vec!["date", "from", "subject"]).stack()? .set_default("hostname", "smtp.2.tg").stack()? .set_default("listen_on", "0.0.0.0:1025").stack()? - .set_default("unknown", "relay").stack()? .set_default("domains", vec!["localhost", - hostname::get().expect("Failed to get current hostname") - .to_str().expect("Can't convert hostname to string, bad UTF-8?")]).stack()? + hostname::get().context("Failed to get current hostname")? + .to_str().context("Can't convert hostname to string, bad UTF-8?")?]).stack()? .add_source(config::File::from(config_file)) .build() .with_context(|| format!("[{config_file:?}] there was an error reading config\n\ \tplease consult \"smtp2tg.toml.example\" for details"))?; @@ -81,12 +76,13 @@ let listen_on = settings.get_string("listen_on").stack()?; let server_name = settings.get_string("hostname").stack()?; let core = MailServer::new(settings)?; let mut server = mailin_embedded::Server::new(core); + // TODO: remove unwraps when mailin-embedded bumps with better error handling server.with_name(server_name) .with_ssl(mailin_embedded::SslConfig::None).unwrap() .with_addr(listen_on).unwrap(); server.serve().unwrap(); Ok(()) } Index: src/mail.rs ================================================================== --- src/mail.rs +++ src/mail.rs @@ -1,5 +1,10 @@ +//! SMTP server implementation for receiving and processing emails. +//! +//! This module handles SMTP connections, email parsing, and forwarding to +//! Telegram. + use crate::{ Cursor, telegram::TelegramTransport, utils::{ Attachment, @@ -27,10 +32,11 @@ OK }, }; use regex::{ Regex, + RegexBuilder, escape, }; use stacked_errors::{ Result, StackableErr, @@ -48,79 +54,83 @@ /// `MailServer` Central object with TG api and configuration #[derive(Clone, Debug)] pub struct MailServer { data: Vec, headers: Option, - relay: bool, tg: Arc, fields: HashSet, address: Regex, } impl MailServer { - /// Initialize API and read configuration - pub fn new(settings: config::Config) -> Result { + /// Initializes the mail server: sets up the Telegram API client and + /// validates all required configuration values. + /// + /// # Arguments + /// * `settings` - Parsed application configuration. + /// + /// # Errors + /// Returns an error if required configuration values are missing or invalid. + /// server fails to start. + pub fn new (settings: config::Config) -> Result { let api_key = settings.get_string("api_key") .context("[smtp2tg.toml] missing \"api_key\" parameter.\n")?; let mut recipients = HashMap::new(); for (name, value) in settings.get_table("recipients") - .expect("[smtp2tg.toml] missing table \"recipients\".\n") + .context("[smtp2tg.toml] missing table \"recipients\".\n")? { let value = value.into_int() .context("[smtp2tg.toml] \"recipient\" table values should be integers.\n")?; - recipients.insert(name, value); + recipients.insert(name.to_lowercase(), value); } let tg = Arc::new(TelegramTransport::new(api_key, recipients, &settings)?); let fields = HashSet::::from_iter(settings.get_array("fields") - .expect("[smtp2tg.toml] \"fields\" should be an array") - .iter().map(|x| x.clone().into_string().expect("should be strings"))); + .context("[smtp2tg.toml] \"fields\" should be an array")? + .iter().map(|x| x.clone().into_string().context("should be strings")) + .collect::>>()?); let mut domains: HashSet = HashSet::new(); let extra_domains = settings.get_array("domains").stack()?; for domain in extra_domains { let domain = domain.to_string().to_lowercase(); if RE_DOMAIN.is_match(&domain) { domains.insert(domain); } else { - panic!("[smtp2tg.toml] can't check of domains in \"domains\": {domain}"); + bail!("[smtp2tg.toml] can't check domains in \"domains\": {domain}"); } } + if domains.is_empty() { + bail!("No domains, need at least one: default `localhost` would do."); + } let domains = domains.into_iter().map(|s| escape(&s)) .collect::>().join("|"); - let address = Regex::new(&format!("^(?P[a-z0-9][-a-z0-9])(@({domains}))$")).stack()?; - let relay = match settings.get_string("unknown") - .context("[smtp2tg.toml] can't get \"unknown\" policy.\n")?.as_str() - { - "relay" => true, - "deny" => false, - _ => { - bail!("[smtp2tg.toml] \"unknown\" should be either \"relay\" or \"deny\".\n"); - }, - }; + let address = RegexBuilder::new(&format!("^[a-z0-9][a-z0-9.-]*(@({domains}))?$")) + .case_insensitive(true).build().stack()?; Ok(MailServer { data: vec!(), headers: None, - relay, tg, fields, address, }) } - /// Returns id for provided email address - pub fn get_id (&self, name_str: &str) -> Result<&ChatPeerId> { - // here we need to store String locally to borrow it after - let mut link = name_str; - let name: String; - if let Some(caps) = self.address.captures(link) { - name = caps["name"].to_string(); - link = &name; - } - match self.tg.get(link) { - Ok(addr) => Ok(addr), - Err(_) => Ok(&self.tg.default), + /// Retrieves the Telegram chat ID for a given email address, checks that + /// used domain is allowed. + /// + /// # Arguments + /// * `name` - Email address or username to look up. + /// + /// # Returns + /// * `Result` - Telegram chat ID for the address, or default if + /// not found. + pub fn get_id (&self, name: &str) -> Result<&ChatPeerId> { + if self.address.is_match(name) { + Ok(self.tg.get(name).unwrap_or(&self.tg.default)) + } else { + bail!("Doesn't look like address from one of our domains."); } } /// Attempt to deliver one message async fn relay_mail (&self) -> Result<()> { @@ -129,11 +139,11 @@ .context("Failed to parse mail.")?; // Adding all known addresses to recipient list, for anyone else adding default // Also if list is empty also adding default let mut rcpt: HashSet<&ChatPeerId> = HashSet::new(); - if headers.to.is_empty() && !self.relay { + if headers.to.is_empty() { bail!("Relaying is disabled, and there's no destination address"); } for item in &headers.to { rcpt.insert(self.get_id(item)?); }; @@ -261,10 +271,11 @@ } Ok(()) } } +/// SMTP handler implementation for mailin-embedded. impl mailin_embedded::Handler for MailServer { /// Just deny login auth fn auth_login (&mut self, _username: &str, _password: &str) -> Response { INVALID_CREDENTIALS } @@ -274,23 +285,14 @@ INVALID_CREDENTIALS } /// Verify whether address is deliverable fn rcpt (&mut self, to: &str) -> Response { - if self.relay { + if self.get_id(to).is_ok() { OK } else { - match self.get_id(to) { - Ok(_) => OK, - Err(_) => { - if self.relay { - OK - } else { - NO_MAILBOX - } - } - } + NO_MAILBOX } } /// Save headers we need fn data_start (&mut self, _domain: &str, from: &str, _is8bit: bool, to: &[String]) -> Response { Index: src/telegram.rs ================================================================== --- src/telegram.rs +++ src/telegram.rs @@ -1,5 +1,7 @@ +//! Telegram API integration for sending messages and attachments. + use crate::utils::{ Attachment, validate, }; @@ -7,10 +9,11 @@ collections::HashMap, fmt::Debug, }; use stacked_errors::{ + bail, Result, StackableErr, }; use tgbot::{ api::Client, @@ -35,11 +38,20 @@ recipients: HashMap, pub default: ChatPeerId, } impl TelegramTransport { - /// Creates new TelegramTransport object. + /// Creates a new `TelegramTransport` instance. + /// + /// # Arguments + /// * `api_key` - Telegram Bot API token. + /// * `recipients` - Mapping of email addresses to Telegram chat IDs. + /// * `settings` - Additional configuration (API gateway, default chat). + /// + /// # Errors + /// Returns an error if configuration values cannot be read or if Telegram + /// API client creation fails. pub fn new (api_key: String, recipients: HashMap, settings: &config::Config) -> Result { let default = settings.get_int("default") .context("[smtp2tg.toml] missing \"default\" recipient.\n")?; let api_gateway = settings.get_string("api_gateway") .context("[smtp2tg.toml] missing \"api_gateway\" destination.\n")?; @@ -56,31 +68,64 @@ recipients, default, }) } - /// Send message to default user, used for debug/log/info purposes + /// Sends a debug message to the default chat. + /// + /// # Arguments + /// * `msg` - Message text to send. + /// + /// # Returns + /// * `Result` - Telegram API response. + /// + /// # Errors + /// Returns an error if `msg` contains a closing Telegram tag or sending fails. pub async fn debug (&self, msg: &str) -> Result { self.send(&self.default, format!("
{}
", validate(msg).stack()?)).await } - /// Get recipient by address + /// Retrieves a chat ID by name. + /// + /// # Arguments + /// * `name` - Name or email to look up. + /// + /// # Returns + /// * `Result<&ChatPeerId>` - Chat ID if found. + /// + /// # Errors + /// Returns an error if `name` is not configured. pub fn get (&self, name: &str) -> Result<&ChatPeerId> { - self.recipients.get(name) + self.recipients.get(&name.to_lowercase()) .with_context(|| format!("Recipient \"{name}\" not found in configuration")) } - /// Send message to specified user + /// Sends a text message to a specified chat. + /// + /// # Arguments + /// * `to` - Target chat ID. + /// * `msg` - Message text (supports HTML formatting). + /// + /// # Returns + /// * `Result` - Telegram API response. pub async fn send (&self, to: &ChatPeerId, msg: S) -> Result where S: Into + Debug{ self.tg.execute( SendMessage::new(*to, msg) .with_parse_mode(Html) ).await.stack() } - /// Send media to specified user + /// Sends a message with attachments to a specified chat. + /// + /// # Arguments + /// * `to` - Target chat ID. + /// * `media` - List of attachments, non-empty. + /// * `msg` - Message text (supports HTML formatting). + /// + /// # Returns + /// * `Result<()>` - Success or error. pub async fn sendgroup (&self, to: &ChatPeerId, media: Vec, msg: &str) -> Result<()> { if media.len() > 1 { let mut attach = vec![]; let mut pos = media.len(); for file in media { @@ -100,10 +145,13 @@ ) ); } self.tg.execute(SendMediaGroup::new(*to, MediaGroup::new(attach).stack()?)).await.stack()?; } else { + if media.is_empty() { + bail!("At least one attachment is required."); + } self.tg.execute( SendDocument::new( *to, InputFileReader::from(media[0].data.clone()) .with_file_name(media[0].name.clone()) Index: src/utils.rs ================================================================== --- src/utils.rs +++ src/utils.rs @@ -1,25 +1,31 @@ +//! Utility functions and types for the application. + use crate::Cursor; -use std::borrow::Cow; +use std::{ + borrow::Cow, + sync::LazyLock, +}; use html_escape::encode_text; -use lazy_static::lazy_static; use regex::{ Regex, RegexBuilder, }; use stacked_errors::{ bail, Result, }; -lazy_static! { - pub static ref RE_DOMAIN: Regex = Regex::new(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$").expect("Invalid domain regex"); - pub static ref RE_CLOSING: Regex = RegexBuilder::new(r"") - .case_insensitive(true).build().expect("Invalid closing tag regex"); -} +pub static RE_DOMAIN: LazyLock = LazyLock::new(|| { + Regex::new(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$").expect("Invalid domain regex") +}); +pub static RE_CLOSING: LazyLock = LazyLock::new(|| { + RegexBuilder::new(r"") + .case_insensitive(true).build().expect("Invalid closing tag regex") +}); /// Stores binary attachment data and metadata for Telegram messages. /// The data is wrapped in a `Cursor>` for efficient streaming, /// while `name` holds the filename or display name of the attachment. #[derive(Debug)] @@ -26,14 +32,24 @@ pub struct Attachment { pub data: Cursor>, pub name: String, } -/// Pass any text here to be validated as not breaking from Telegram preformatted blocks -/// escape all HTML chars afterwards +/// Validates text to ensure it doesn't break Telegram's preformatted blocks. +/// +/// Escapes HTML special characters to prevent injection. +/// +/// # Arguments +/// * `text` - Text to validate and escape. +/// +/// # Returns +/// * `Result>` - Escaped text or error if invalid. +/// +/// # Errors +/// Returns an error if the text contains Telegram closing tags (``, ``). pub fn validate <'a>(text: &'a str) -> Result> { if RE_CLOSING.is_match(text) { bail!("Telegram closing tag found."); } else { Ok(encode_text(text)) } } Index: tests/mail.rs ================================================================== --- tests/mail.rs +++ tests/mail.rs @@ -1,43 +1,99 @@ use smtp2tg::mail::MailServer; +use config::FileFormat::Toml; use stacked_errors::{ Result, StackableErr, + ensure, + ensure_eq, }; use tgbot::types::ChatPeerId; -/// Builds a `MailServer` purely from an in-memory TOML source, no -/// network access is performed while constructing it. -fn build_server () -> Result { - let settings = config::Config::builder() + +#[test] +fn get_id_properly_resolves_addresses () -> Result<()> { + let server = MailServer::new(config::Config::builder() .add_source(config::File::from_str(r#" api_key = "test-api-key" api_gateway = "https://api.telegram.org" default = 0 - unknown = "relay" fields = ["date", "from", "subject"] domains = ["example.com"] [recipients] "someone@example.com" = 1 "root" = -1 - "#, config::FileFormat::Toml)) + "#, Toml)) .build() - .stack()?; - MailServer::new(settings) -} - -#[test] -fn get_id_returns_configured_recipient () -> Result<()> { - let server = build_server()?; + .stack()?)?; let cases = [ ("someone@example.com", 1), ("someone", 0), ("root", -1), ("unknown@example.com", 0), + ("SOMEONE@example.com", 1), // uppercase local part + ("someone@EXAMPLE.COM", 1), // uppercase domain + ("some.one@example.com", 0), // dot are not skipped + ("some-one-2", 0), // Hyphens ]; for (email, id) in cases { - assert_eq!(*server.get_id(email)?, ChatPeerId::from(id), "email [{email}] expected to return id [{id}]"); + ensure_eq!(*server.get_id(email)?, ChatPeerId::from(id), format!("email [{email}] expected to return id [{id}]")); + } + let cases = [ + "someone@otherdomain.net", + "@example.com", // empty local part + "some@one@example.com", // more than one '@' + "someone@example.com.evil", + "someone@example.org", + ]; + for email in cases { + ensure!(server.get_id(email).is_err(), format!("this email should be rejected: {email}")); + } + Ok(()) +} + +#[test] +fn wrong_server_config () -> Result<()> { + let configs = [ + "domains = []", + "", + "[recipents]\na = 1", + r#" + api_key = "test-api-key" + api_gateway = "https://api.telegram.org" + default = 0 + fields = ["date", "from", "subject"] + domains = ["example.com"] + # no recipients + "#, + r#" + ap_key = "test-api-key" # bad one + api_gateway = "https://api.telegram.org" + default = 0 + fields = ["date", "from", "subject"] + domains = ["example.com"] + + [recipients] + "someone@example.com" = 1 + "root" = -1"#, + r#" + api_key = "test-api-key" + api_gateway = "https://api.telegram.org" + default = 0 + fields = ["date", "from", "subject"] + domains = [] # empty + + [recipients] + "someone@example.com" = 1 + "root" = -1"#, + ]; + for config in configs { + let settings = config::Config::builder() + .add_source(config::File::from_str(config, Toml)) + .build() + .stack()?; + ensure!(MailServer::new(settings).is_err(), format!("this config shouldn't be valid:\n{config}")); } + Ok(()) } Index: tests/utils.rs ================================================================== --- tests/utils.rs +++ tests/utils.rs @@ -7,11 +7,14 @@ use std::{ borrow::Cow, mem::discriminant, }; -use stacked_errors::Result; +use stacked_errors::{ + Result, + ensure_eq, +}; #[test] fn test_validate_escaping_behavior () -> Result<()> { let cases: &[(&str, Cow)] = &[ // `validate` escapes HTML special characters. @@ -27,18 +30,18 @@ ("plain text without special html characters", Cow::Borrowed("plain text without special html characters")), ("5 > 3 & 2 < 4", Cow::Owned("5 > 3 & 2 < 4".into())), ]; for (input, expected) in cases { let result = validate(input)?; - assert_eq!(&result, expected, "unexpected output for input {input:?}"); - assert_eq!(discriminant(&result), discriminant(expected), "wrong Cow variant for input {input:?}"); + ensure_eq!(&result, expected, format!("unexpected output for input {input:?}")); + ensure_eq!(discriminant(&result), discriminant(expected), format!("wrong Cow variant for input {input:?}")); } Ok(()) } #[test] -fn test_validate_closing_tag_behavior () { +fn test_validate_closing_tag_behavior () -> Result<()> { let cases = [ ("", true), ("", true), ("", false), ("", true), @@ -51,16 +54,17 @@ ("\n", true), ("

Some valid HTML

Link injection!", true), ("
", false),
 	];
 	for (input, expected) in cases {
-		assert_eq!(RE_CLOSING.is_match(input), expected, "unexpected match result for {input:?}");
+		ensure_eq!(RE_CLOSING.is_match(input), expected, format!("unexpected match result for {input:?}"));
 	}
+	Ok(())
 }
 
 #[test]
-fn test_regex_domain_behavior() {
+fn test_regex_domain_behavior() -> Result<()> {
 	let cases = [
 		("", false),
 		("-example.com", false),
 		(".example.com", false),
 		("123.456", true),
@@ -74,8 +78,9 @@
 		("invalid@domain.com", false),
 		("my-host.example.com", true),
 		("sub.example.co.uk", true),
 	];
 	for (input, expected) in cases {
-		assert_eq!(RE_DOMAIN.is_match(input), expected, "unexpected match result for {input:?}");
+		ensure_eq!(RE_DOMAIN.is_match(input), expected, format!("unexpected match result for {input:?}"));
 	}
+	Ok(())
 }