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" @@ -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" @@ -1077,10 +1179,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 +1777,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 +2128,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,12 +9,11 @@ [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" +clap = { version = "4.6", features = [ "derive" ] } mail-parser = { version = "0.11", features = ["serde"] } mailin-embedded = "0.8" regex = "1.11.1" smol = "2.0.2" stacked_errors = "0.7.1" 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()?; 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, @@ -55,11 +60,18 @@ fields: HashSet, address: Regex, } impl MailServer { - /// Initialize API and read configuration + /// 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 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") @@ -105,11 +117,19 @@ fields, address, }) } - /// Returns id for provided email address + /// Retrieves the Telegram chat ID for a given email address, checks that + /// used domain is allowed. + /// + /// # Arguments + /// * `name_str` - 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) { match self.tg.get(name) { Ok(addr) => Ok(addr), Err(_) => Ok(&self.tg.default), @@ -258,10 +278,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 } 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, }; @@ -35,11 +37,19 @@ 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 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 +66,58 @@ 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. 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. pub fn get (&self, name: &str) -> Result<&ChatPeerId> { self.recipients.get(name) .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. + /// * `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 { Index: src/utils.rs ================================================================== --- src/utils.rs +++ src/utils.rs @@ -1,5 +1,7 @@ +//! Utility functions and types for the application. + use crate::Cursor; use std::{ borrow::Cow, sync::LazyLock, @@ -30,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)) } }