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
{}", 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
+ /// * `ResultSome 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(())
}