Index: Cargo.lock ================================================================== --- Cargo.lock +++ Cargo.lock @@ -169,11 +169,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", ] [[package]] name = "async-task" version = "4.7.1" @@ -184,16 +184,10 @@ name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "autocfg" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - [[package]] name = "aws-lc-rs" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fcc8f365936c834db5514fc45aee5b1202d677e6b40e48468aaaa8183ca8c7" @@ -262,11 +256,11 @@ "proc-macro2", "quote", "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.102", + "syn 2.0.104", "which", ] [[package]] name = "bitflags" @@ -311,13 +305,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.26" +version = "1.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac" +checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" dependencies = [ "jobserver", "libc", "shlex", ] @@ -409,11 +403,11 @@ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.102", + "syn 2.0.104", ] [[package]] name = "darling_macro" version = "0.20.11" @@ -420,11 +414,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.102", + "syn 2.0.104", ] [[package]] name = "derive_more" version = "2.0.1" @@ -440,11 +434,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", ] [[package]] name = "displaydoc" version = "0.2.5" @@ -451,11 +445,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", ] [[package]] name = "dunce" version = "1.0.5" @@ -483,16 +477,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "event-listener" version = "2.5.3" @@ -587,11 +581,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", ] [[package]] name = "futures-sink" version = "0.3.31" @@ -684,11 +678,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f208758247e68e239acaa059e72e4ce1f30f2a4b6523f19c1b923d25b7e9cceb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", ] [[package]] name = "hermit-abi" version = "0.5.2" @@ -1016,22 +1010,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.172" +version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" [[package]] name = "libloading" version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.1", + "windows-targets 0.53.2", ] [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1251,16 +1245,16 @@ "zerocopy", ] [[package]] name = "prettyplease" -version = "0.2.33" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dee91521343f4c5c6a63edd65e54f31f5c92fe8978c40a4282f8372194c6a7d" +checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a" dependencies = [ "proc-macro2", - "syn 2.0.102", + "syn 2.0.104", ] [[package]] name = "proc-macro2" version = "1.0.95" @@ -1311,13 +1305,13 @@ "web-time", ] [[package]] name = "quinn-udp" -version = "0.5.12" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842" +checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", @@ -1334,13 +1328,13 @@ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1498,13 +1492,13 @@ "windows-sys 0.59.0", ] [[package]] name = "rustls" -version = "0.23.27" +version = "0.23.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" +checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" dependencies = [ "aws-lc-rs", "log", "once_cell", "ring", @@ -1578,11 +1572,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", ] [[package]] name = "serde_json" version = "1.0.140" @@ -1616,29 +1610,29 @@ "serde", ] [[package]] name = "serde_with" -version = "3.12.0" +version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +checksum = "bf65a400f8f66fb7b0552869ad70157166676db75ed8181f8104ea91cf9d0b42" dependencies = [ "serde", "serde_derive", "serde_with_macros", ] [[package]] name = "serde_with_macros" -version = "3.12.0" +version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +checksum = "81679d9ed988d5e9a5e6531dc3f2c28efbd639cbd1dfb628df08edea6004da77" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", ] [[package]] name = "shellwords" version = "1.1.0" @@ -1655,26 +1649,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smtp2tg" -version = "0.4.1" +version = "0.5.0" dependencies = [ "anyhow", "async-std", "config", "hostname", @@ -1682,11 +1673,10 @@ "lazy_static", "mail-parser", "mailin-embedded", "regex", "tgbot", - "thiserror", ] [[package]] name = "socket2" version = "0.5.10" @@ -1726,13 +1716,13 @@ "unicode-ident", ] [[package]] name = "syn" -version = "2.0.102" +version = "2.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6397daf94fa90f058bd0fd88429dd9e5738999cca8d701813c80723add80462" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] @@ -1752,11 +1742,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", ] [[package]] name = "ternop" version = "1.0.1" @@ -1763,13 +1753,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d4ae32d0a4605a89c28534371b056919c12e7a070ee07505af75130ff030111" [[package]] name = "tgbot" -version = "0.36.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ba13ab4b7a3241c72cbbd997f95dce32bbb3e8c49b09813a151ba8c68c1b08" +checksum = "a5280aeee7c4600846513c67329304a100e6ab4295e6389e6194aa16b5e73595" dependencies = [ "async-stream", "bytes", "derive_more", "futures-util", @@ -1800,11 +1790,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", ] [[package]] name = "tinystr" version = "0.8.1" @@ -2057,11 +2047,11 @@ dependencies = [ "bumpalo", "log", "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" @@ -2092,11 +2082,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] @@ -2141,13 +2131,13 @@ "wasm-bindgen", ] [[package]] name = "webpki-roots" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" +checksum = "8782dd5a41a24eed3a4f40b606249b3e236ca61adf1f25ea4d45c73de122b502" dependencies = [ "rustls-pki-types", ] [[package]] @@ -2162,13 +2152,13 @@ "rustix 0.38.44", ] [[package]] name = "windows-link" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3bfe459f85da17560875b8bf1423d6f113b7a87a5d942e7da0ac71be7c61f8b" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2183,10 +2173,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets 0.52.6", ] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2202,13 +2201,13 @@ "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows-targets" -version = "0.53.1" +version = "0.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30357ec391cde730f8fbfcdc29adc47518b06504528df977ab5af02ef23fdee9" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" dependencies = [ "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", "windows_i686_gnullvm 0.53.0", @@ -2356,32 +2355,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.25" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.25" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", ] [[package]] name = "zerofrom" version = "0.1.6" @@ -2397,11 +2396,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", "synstructure", ] [[package]] name = "zeroize" @@ -2437,7 +2436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.102", + "syn 2.0.104", ] Index: Cargo.toml ================================================================== --- Cargo.toml +++ Cargo.toml @@ -1,8 +1,8 @@ [package] name = "smtp2tg" -version = "0.4.1" +version = "0.5.0" authors = [ "arcade" ] edition = "2021" [dependencies] anyhow = "1.0.86" @@ -10,13 +10,12 @@ config = { version = "0.15", default-features = false, features = [ "toml" ] } hostname = "0.4.1" just-getopt = "2.0.0" lazy_static = "1.5.0" regex = "1.11.1" -tgbot = "0.36.1" -thiserror = "2.0.11" +tgbot = "0.37" mail-parser = { version = "0.11", features = ["serde"] } mailin-embedded = "^0" [profile.release] lto = true codegen-units = 1 Index: smtp2tg.toml.example ================================================================== --- smtp2tg.toml.example +++ smtp2tg.toml.example @@ -1,5 +1,6 @@ +# vi:ft=toml: # Telegram API key api_key = "YOU_KNOW_WHERE_TO_GET_THIS" # where to listen on (sockets are not supported since 0.3.0) listen_on = "0.0.0.0:25" @@ -15,17 +16,17 @@ # which domains are allowed in addresses # this means that any unqualified recipient "somebody" will also match # to "somebody@each_domain" domains = [ "localhost", "current.hostname" ] -[recipients] -# there should be default recipient, get's some debug info + mail that we -# couldn't deliver (if enabled) -_ = 1 +# default recipient, should be specified +# still can be a user, channel or group +default = 0 +[recipients] # make sure you quote emails, as "@" can't go there unquoted. And by default # we need FQDNs "somebody@example.com" = 1 # user id's are positive -"root@example.com" = -1 # group id's are negative +"root" = -1 # group id's are negative # to look up chat/group id you can use debug settings in Telegram clients, # or some bot like @getidsbot or @RawDataBot ADDED src/mail.rs Index: src/mail.rs ================================================================== --- /dev/null +++ src/mail.rs @@ -0,0 +1,334 @@ +use crate::{ + Cursor, + telegram::{ + encode, + TelegramTransport, + }, + utils::{ + Attachment, + RE_DOMAIN, + }, +}; + +use std::{ + borrow::Cow, + collections::{ + HashMap, + HashSet, + }, + io::Error, +}; + +use anyhow::{ + bail, + Context, + Result, +}; +use async_std::{ + sync::Arc, + task, +}; +use mailin_embedded::{ + Response, + response::{ + INTERNAL_ERROR, + INVALID_CREDENTIALS, + NO_MAILBOX, + OK + }, +}; +use regex::{ + Regex, + escape, +}; +use tgbot::types::ChatPeerId; + +/// `SomeHeaders` object to store data through SMTP session +#[derive(Clone, Debug)] +struct SomeHeaders { + from: String, + to: Vec, +} + +/// `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 { + 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") + { + let value = value.into_int() + .context("[smtp2tg.toml] \"recipient\" table values should be integers.\n")?; + recipients.insert(name, value); + } + let default = settings.get_int("default") + .context("[smtp2tg.toml] missing \"default\" recipient.\n")?; + + let tg = Arc::new(TelegramTransport::new(api_key, recipients, default)?); + 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"))); + let mut domains: HashSet = HashSet::new(); + let extra_domains = settings.get_array("domains").unwrap(); + 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}"); + } + } + let domains = domains.into_iter().map(|s| escape(&s)) + .collect::>().join("|"); + let address = Regex::new(&format!("^(?P[a-z0-9][-a-z0-9])(@({domains}))$")).unwrap(); + 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"); + }, + }; + + Ok(MailServer { + data: vec!(), + headers: None, + relay, + tg, + fields, + address, + }) + } + + /// Returns id for provided email address + fn get_id (&self, name: &str) -> Result<&ChatPeerId> { + // here we need to store String locally to borrow it after + let mut link = name; + 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), + } + } + + /// Attempt to deliver one message + async fn relay_mail (&self) -> Result<()> { + if let Some(headers) = &self.headers { + let mail = mail_parser::MessageParser::new().parse(&self.data) + .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 { + bail!("Relaying is disabled, and there's no destination address"); + } + for item in &headers.to { + rcpt.insert(self.get_id(item)?); + }; + if rcpt.is_empty() { + self.tg.debug("No recipient or envelope address.").await?; + rcpt.insert(&self.tg.default); + }; + + // prepating message header + let mut reply: Vec = vec![]; + if self.fields.contains("subject") { + if let Some(subject) = mail.subject() { + reply.push(format!("__*Subject:*__ `{}`", encode(subject))); + } else if let Some(thread) = mail.thread_name() { + reply.push(format!("__*Thread:*__ `{}`", encode(thread))); + } + } + let mut short_headers: Vec = vec![]; + // do we need to replace spaces here? + if self.fields.contains("from") { + short_headers.push(format!("__*From:*__ `{}`", encode(&headers.from))); + } + if self.fields.contains("date") { + if let Some(date) = mail.date() { + short_headers.push(format!("__*Date:*__ `{date}`")); + } + } + reply.push(short_headers.join(" ")); + let header_size = reply.join(" ").len() + 1; + + let html_parts = mail.html_body_count(); + let text_parts = mail.text_body_count(); + let attachments = mail.attachment_count(); + if html_parts != text_parts { + self.tg.debug(&format!("Hm, we have {html_parts} HTML parts and {text_parts} text parts.")).await?; + } + //let mut html_num = 0; + let mut text_num = 0; + let mut file_num = 0; + // let's display first html or text part as body + let mut body: Cow<'_, str> = "".into(); + /* + * actually I don't wanna parse that html stuff + if html_parts > 0 { + let text = mail.body_html(0).unwrap(); + if text.len() < 4096 - header_size { + body = text; + html_num = 1; + } + }; + */ + if body.is_empty() && text_parts > 0 { + let text = mail.body_text(0) + .context("Failed to extract text from message")?; + if text.len() < 4096 - header_size { + body = text; + text_num = 1; + } + }; + reply.push("```".into()); + reply.extend(body.lines().map(|x| x.into())); + reply.push("```".into()); + + // and let's collect all other attachment parts + let mut files_to_send = vec![]; + /* + * let's just skip html parts for now, they just duplicate text? + while html_num < html_parts { + files_to_send.push(mail.html_part(html_num).unwrap()); + html_num += 1; + } + */ + while text_num < text_parts { + files_to_send.push(mail.text_part(text_num.try_into()?) + .context("Failed to get text part from message.")?); + text_num += 1; + } + while file_num < attachments { + files_to_send.push(mail.attachment(file_num.try_into()?) + .context("Failed to get file part from message.")?); + file_num += 1; + } + + let msg = reply.join("\n"); + for chat in rcpt { + if !files_to_send.is_empty() { + let mut files = vec![]; + // let mut first_one = true; + for chunk in &files_to_send { + let data: Vec = chunk.contents().to_vec(); + let mut filename: Option = None; + for header in chunk.headers() { + if header.name() == "Content-Type" { + match header.value() { + mail_parser::HeaderValue::ContentType(contenttype) => { + if let Some(fname) = contenttype.attribute("name") { + filename = Some(fname.to_owned()); + } + }, + _ => { + self.tg.debug("Attachment has bad ContentType header.").await?; + }, + }; + }; + }; + let filename = if let Some(fname) = filename { + fname + } else { + "Attachment.txt".into() + }; + files.push(Attachment { + data: Cursor::new(data), + name: filename, + }); + } + self.tg.sendgroup(chat, files, &msg).await?; + } else { + self.tg.send(chat, &msg).await?; + } + } + } else { + bail!("Required headers were not found."); + } + Ok(()) + } +} + +impl mailin_embedded::Handler for MailServer { + /// Just deny login auth + fn auth_login (&mut self, _username: &str, _password: &str) -> Response { + INVALID_CREDENTIALS + } + + /// Just deny plain auth + fn auth_plain (&mut self, _authorization_id: &str, _authentication_id: &str, _password: &str) -> Response { + INVALID_CREDENTIALS + } + + /// Verify whether address is deliverable + fn rcpt (&mut self, to: &str) -> Response { + if self.relay { + OK + } else { + match self.get_id(to) { + Ok(_) => OK, + Err(_) => { + if self.relay { + OK + } else { + NO_MAILBOX + } + } + } + } + } + + /// Save headers we need + fn data_start (&mut self, _domain: &str, from: &str, _is8bit: bool, to: &[String]) -> Response { + self.headers = Some(SomeHeaders{ + from: from.to_string(), + to: to.to_vec(), + }); + OK + } + + /// Save chunk(?) of data + fn data (&mut self, buf: &[u8]) -> Result<(), Error> { + self.data.append(buf.to_vec().as_mut()); + Ok(()) + } + + /// Attempt to send email, return temporary error if that fails + fn data_end (&mut self) -> Response { + let mut result = OK; + task::block_on(async { + // relay mail + if let Err(err) = self.relay_mail().await { + result = INTERNAL_ERROR; + // in case that fails - inform default recipient + if let Err(err) = self.tg.debug(&format!("Sending emails failed:\n{err:?}")).await { + // in case that also fails - write some logs and bail + eprintln!("{err:?}"); + }; + }; + }); + // clear - just in case + self.data = vec![]; + self.headers = None; + result + } +} Index: src/main.rs ================================================================== --- src/main.rs +++ src/main.rs @@ -1,458 +1,37 @@ //! Simple SMTP-to-Telegram gateway. Can parse email and send them as telegram //! messages to specified chats, generally you specify which email address is //! available in configuration, everything else is sent to default address. -use anyhow::Result; +mod mail; +mod telegram; +mod utils; + +#[cfg(test)] +mod tests; + +use crate::mail::MailServer; + +use anyhow::{ + bail, + Context, + Result, +}; use async_std::{ fs::metadata, - io::Error, - task, }; use just_getopt::{ OptFlags, OptSpecs, OptValue, }; -use lazy_static::lazy_static; -use mailin_embedded::{ - Response, - response::*, -}; -use regex::{ - Regex, - escape, -}; -use tgbot::{ - api::Client, - types::{ - ChatPeerId, - InputFile, - InputFileReader, - InputMediaDocument, - MediaGroup, - MediaGroupItem, - Message, - ParseMode::MarkdownV2, - SendDocument, - SendMediaGroup, - SendMessage, - }, -}; -use thiserror::Error; use std::{ - borrow::Cow, - collections::{ - HashMap, - HashSet, - }, io::Cursor, os::unix::fs::PermissionsExt, path::Path, - vec::Vec, -}; - -#[derive(Error, Debug)] -pub enum MyError { - #[error("Failed to parse mail")] - BadMail, - #[error("Missing default address in recipient table")] - NoDefault, - #[error("No headers found")] - NoHeaders, - #[error("No recipient addresses")] - NoRecipient, - #[error("Failed to extract text from message")] - NoText, - #[error(transparent)] - RequestError(#[from] tgbot::api::ExecuteError), - #[error(transparent)] - TryFromIntError(#[from] std::num::TryFromIntError), - #[error(transparent)] - InputMediaError(#[from] tgbot::types::InputMediaError), - #[error(transparent)] - MediaGroupError(#[from] tgbot::types::MediaGroupError), -} - -/// `SomeHeaders` object to store data through SMTP session -#[derive(Clone, Debug)] -struct SomeHeaders { - from: String, - to: Vec, -} - -struct Attachment { - data: Cursor>, - name: String, -} - -/// `TelegramTransport` Central object with TG api and configuration -#[derive(Clone)] -struct TelegramTransport { - data: Vec, - headers: Option, - recipients: HashMap, - relay: bool, - tg: Client, - fields: HashSet, - address: Regex, -} - -lazy_static! { - static ref RE_SPECIAL: Regex = Regex::new(r"([\-_*\[\]()~`>#+|{}\.!])").unwrap(); - 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])?)*$").unwrap(); -} - -/// Encodes special HTML entities to prevent them interfering with Telegram HTML -fn encode (text: &str) -> Cow<'_, str> { - RE_SPECIAL.replace_all(text, "\\$1") -} - -#[cfg(test)] -mod tests { - use crate::encode; - - #[test] - fn check_regex () { - let res = encode("-_*[]()~`>#+|{}.!"); - assert_eq!(res, "\\-\\_\\*\\[\\]\\(\\)\\~\\`\\>\\#\\+\\|\\{\\}\\.\\!"); - } -} - -impl TelegramTransport { - /// Initialize API and read configuration - fn new(settings: config::Config) -> TelegramTransport { - let tg = Client::new(settings.get_string("api_key") - .expect("[smtp2tg.toml] missing \"api_key\" parameter.\n")) - .expect("Failed to create API.\n"); - let recipients: HashMap = settings.get_table("recipients") - .expect("[smtp2tg.toml] missing table \"recipients\".\n") - .into_iter().map(|(a, b)| (a, ChatPeerId::from(b.into_int() - .expect("[smtp2tg.toml] \"recipient\" table values should be integers.\n") - ))).collect(); - if !recipients.contains_key("_") { - eprintln!("[smtp2tg.toml] \"recipient\" table misses \"default_recipient\".\n"); - panic!("no default recipient"); - } - 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"))); - let value = settings.get_string("unknown"); - let mut domains: HashSet = HashSet::new(); - let extra_domains = settings.get_array("domains").unwrap(); - 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}"); - } - } - let domains = domains.into_iter().map(|s| escape(&s)) - .collect::>().join("|"); - let address = Regex::new(&format!("^(?P[a-z0-9][-a-z0-9])(@({domains}))$")).unwrap(); - let relay = match value { - Ok(value) => { - match value.as_str() { - "relay" => true, - "deny" => false, - _ => { - eprintln!("[smtp2tg.toml] \"unknown\" should be either \"relay\" or \"deny\".\n"); - panic!("bad setting"); - }, - } - }, - Err(err) => { - eprintln!("[smtp2tg.toml] can't get \"unknown\":\n {err:?}\n"); - panic!("bad setting"); - }, - }; - - TelegramTransport { - data: vec!(), - headers: None, - recipients, - relay, - tg, - fields, - address, - } - } - - /// Send message to default user, used for debug/log/info purposes - async fn debug (&self, msg: &str) -> Result { - self.send(self.recipients.get("_").ok_or(MyError::NoDefault)?, encode(msg)).await - } - - /// Send message to specified user - async fn send (&self, to: &ChatPeerId, msg: S) -> Result - where S: Into { - Ok(self.tg.execute( - SendMessage::new(*to, msg) - .with_parse_mode(MarkdownV2) - ).await?) - } - - /// Returns id for provided email address - fn get_id (&self, name: &str) -> Result<&ChatPeerId, MyError> { - // here we need to store String locally to borrow it after - let mut link = name; - let name: String; - if let Some(caps) = self.address.captures(link) { - name = caps["name"].to_string(); - link = &name; - } - match self.recipients.get(link) { - Some(addr) => Ok(addr), - None => { - self.recipients.get("_") - .ok_or(MyError::NoDefault) - } - } - } - - /// Attempt to deliver one message - async fn relay_mail (&self) -> Result<(), MyError> { - if let Some(headers) = &self.headers { - let mail = mail_parser::MessageParser::new().parse(&self.data) - .ok_or(MyError::BadMail)?; - - // 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() { - return Err(MyError::NoRecipient); - } - for item in &headers.to { - rcpt.insert(self.get_id(item)?); - }; - if rcpt.is_empty() { - self.debug("No recipient or envelope address.").await?; - rcpt.insert(self.recipients.get("_") - .ok_or(MyError::NoDefault)?); - }; - - // prepating message header - let mut reply: Vec = vec![]; - if self.fields.contains("subject") { - if let Some(subject) = mail.subject() { - reply.push(format!("__*Subject:*__ `{}`", encode(subject))); - } else if let Some(thread) = mail.thread_name() { - reply.push(format!("__*Thread:*__ `{}`", encode(thread))); - } - } - let mut short_headers: Vec = vec![]; - // do we need to replace spaces here? - if self.fields.contains("from") { - short_headers.push(format!("__*From:*__ `{}`", encode(&headers.from))); - } - if self.fields.contains("date") { - if let Some(date) = mail.date() { - short_headers.push(format!("__*Date:*__ `{date}`")); - } - } - reply.push(short_headers.join(" ")); - let header_size = reply.join(" ").len() + 1; - - let html_parts = mail.html_body_count(); - let text_parts = mail.text_body_count(); - let attachments = mail.attachment_count(); - if html_parts != text_parts { - self.debug(&format!("Hm, we have {html_parts} HTML parts and {text_parts} text parts.")).await?; - } - //let mut html_num = 0; - let mut text_num = 0; - let mut file_num = 0; - // let's display first html or text part as body - let mut body: Cow<'_, str> = "".into(); - /* - * actually I don't wanna parse that html stuff - if html_parts > 0 { - let text = mail.body_html(0).unwrap(); - if text.len() < 4096 - header_size { - body = text; - html_num = 1; - } - }; - */ - if body.is_empty() && text_parts > 0 { - let text = mail.body_text(0) - .ok_or(MyError::NoText)?; - if text.len() < 4096 - header_size { - body = text; - text_num = 1; - } - }; - reply.push("```".into()); - reply.extend(body.lines().map(|x| x.into())); - reply.push("```".into()); - - // and let's collect all other attachment parts - let mut files_to_send = vec![]; - /* - * let's just skip html parts for now, they just duplicate text? - while html_num < html_parts { - files_to_send.push(mail.html_part(html_num).unwrap()); - html_num += 1; - } - */ - while text_num < text_parts { - files_to_send.push(mail.text_part(text_num.try_into()?) - .ok_or(MyError::NoText)?); - text_num += 1; - } - while file_num < attachments { - files_to_send.push(mail.attachment(file_num.try_into()?) - .ok_or(MyError::NoText)?); - file_num += 1; - } - - let msg = reply.join("\n"); - for chat in rcpt { - if !files_to_send.is_empty() { - let mut files = vec![]; - // let mut first_one = true; - for chunk in &files_to_send { - let data: Vec = chunk.contents().to_vec(); - let mut filename: Option = None; - for header in chunk.headers() { - if header.name() == "Content-Type" { - match header.value() { - mail_parser::HeaderValue::ContentType(contenttype) => { - if let Some(fname) = contenttype.attribute("name") { - filename = Some(fname.to_owned()); - } - }, - _ => { - self.debug("Attachment has bad ContentType header.").await?; - }, - }; - }; - }; - let filename = if let Some(fname) = filename { - fname - } else { - "Attachment.txt".into() - }; - files.push(Attachment { - data: Cursor::new(data), - name: filename, - }); - } - self.sendgroup(chat, files, &msg).await?; - } else { - self.send(chat, &msg).await?; - } - } - } else { - return Err(MyError::NoHeaders); - } - Ok(()) - } - - /// Send media to specified user - pub async fn sendgroup (&self, to: &ChatPeerId, media: Vec, msg: &str) -> Result<(), MyError> { - if media.len() > 1 { - let mut attach = vec![]; - let mut pos = media.len(); - for file in media { - let mut caption = InputMediaDocument::default(); - if pos == 1 { - caption = caption.with_caption(msg) - .with_caption_parse_mode(MarkdownV2); - } - pos -= 1; - attach.push( - MediaGroupItem::for_document( - InputFile::from( - InputFileReader::from(file.data) - .with_file_name(file.name) - ), - caption - ) - ); - } - self.tg.execute(SendMediaGroup::new(*to, MediaGroup::new(attach)?)).await?; - } else { - self.tg.execute( - SendDocument::new( - *to, - InputFileReader::from(media[0].data.clone()) - .with_file_name(media[0].name.clone()) - ).with_caption(msg) - .with_caption_parse_mode(MarkdownV2) - ).await?; - } - Ok(()) - } -} - -impl mailin_embedded::Handler for TelegramTransport { - /// Just deny login auth - fn auth_login (&mut self, _username: &str, _password: &str) -> Response { - INVALID_CREDENTIALS - } - - /// Just deny plain auth - fn auth_plain (&mut self, _authorization_id: &str, _authentication_id: &str, _password: &str) -> Response { - INVALID_CREDENTIALS - } - - /// Verify whether address is deliverable - fn rcpt (&mut self, to: &str) -> Response { - if self.relay { - OK - } else { - match self.get_id(to) { - Ok(_) => OK, - Err(_) => { - if self.relay { - OK - } else { - NO_MAILBOX - } - } - } - } - } - - /// Save headers we need - fn data_start (&mut self, _domain: &str, from: &str, _is8bit: bool, to: &[String]) -> Response { - self.headers = Some(SomeHeaders{ - from: from.to_string(), - to: to.to_vec(), - }); - OK - } - - /// Save chunk(?) of data - fn data (&mut self, buf: &[u8]) -> Result<(), Error> { - self.data.append(buf.to_vec().as_mut()); - Ok(()) - } - - /// Attempt to send email, return temporary error if that fails - fn data_end (&mut self) -> Response { - let mut result = OK; - task::block_on(async { - // relay mail - if let Err(err) = self.relay_mail().await { - result = INTERNAL_ERROR; - // in case that fails - inform default recipient - if let Err(err) = self.debug(&format!("Sending emails failed:\n{err:?}")).await { - // in case that also fails - write some logs and bail - eprintln!("{err:?}"); - }; - }; - }); - // clear - just in case - self.data = vec![]; - self.headers = None; - result - } -} +}; #[async_std::main] async fn main () -> Result<()> { let specs = OptSpecs::new() .option("help", "h", OptValue::None) @@ -467,29 +46,27 @@ println!("Unknown option: {u}"); } if !(parsed.unknown.is_empty()) || parsed.options_first("help").is_some() { println!("SMTP2TG v{}, (C) 2024 - 2025\n\n\ \t-h|--help\tDisplay this help\n\ - \t-c|-config …\tSet configuration file location.", + \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" }); if !config_file.exists() { - eprintln!("Error: can't read configuration from {config_file:?}"); - std::process::exit(1); + bail!("can't read configuration from {config_file:?}"); }; { let meta = metadata(config_file).await?; if (!0o100600 & meta.permissions().mode()) > 0 { - eprintln!("Error: other users can read or write config file {config_file:?}\n\ + bail!("other users can read or write config file {config_file:?}\n\ File permissions: {:o}", meta.permissions().mode()); - std::process::exit(1); } } let settings: config::Config = config::Config::builder() .set_default("fields", vec!["date", "from", "subject"]).unwrap() .set_default("hostname", "smtp.2.tg").unwrap() @@ -496,20 +73,20 @@ .set_default("listen_on", "0.0.0.0:1025").unwrap() .set_default("unknown", "relay").unwrap() .set_default("domains", vec!["localhost", hostname::get()?.to_str().expect("Failed to get current hostname")]).unwrap() .add_source(config::File::from(config_file)) .build() - .unwrap_or_else(|_| panic!("[{config_file:?}] there was an error reading config\n\ - \tplease consult \"smtp2tg.toml.example\" for details")); + .with_context(|| format!("[{config_file:?}] there was an error reading config\n\ + \tplease consult \"smtp2tg.toml.example\" for details"))?; let listen_on = settings.get_string("listen_on")?; let server_name = settings.get_string("hostname")?; - let core = TelegramTransport::new(settings); + let core = MailServer::new(settings)?; let mut server = mailin_embedded::Server::new(core); server.with_name(server_name) .with_ssl(mailin_embedded::SslConfig::None).unwrap() .with_addr(listen_on).unwrap(); server.serve().unwrap(); Ok(()) } ADDED src/telegram.rs Index: src/telegram.rs ================================================================== --- /dev/null +++ src/telegram.rs @@ -0,0 +1,116 @@ +use crate::utils::{ + Attachment, + RE_SPECIAL, +}; + +use std::{ + borrow::Cow, + collections::HashMap, + fmt::Debug, +}; + +use anyhow::{ + Context, + Result, +}; +use tgbot::{ + api::Client, + types::{ + ChatPeerId, + InputFile, + InputFileReader, + InputMediaDocument, + MediaGroup, + MediaGroupItem, + Message, + ParseMode::MarkdownV2, + SendMediaGroup, + SendMessage, + SendDocument, + }, +}; + +/// Encodes special HTML entities to prevent them interfering with Telegram HTML +pub fn encode (text: &str) -> Cow<'_, str> { + RE_SPECIAL.replace_all(text, "\\$1") +} + +#[derive(Debug)] +pub struct TelegramTransport { + tg: Client, + recipients: HashMap, + pub default: ChatPeerId, +} + +impl TelegramTransport { + + pub fn new (api_key: String, recipients: HashMap, default: i64) -> Result { + let tg = Client::new(api_key) + .context("Failed to create API.\n")?; + let recipients = recipients.into_iter() + .map(|(a, b)| (a, ChatPeerId::from(b))).collect(); + let default = ChatPeerId::from(default); + + Ok(TelegramTransport { + tg, + recipients, + default, + }) + } + + /// Send message to default user, used for debug/log/info purposes + pub async fn debug (&self, msg: &str) -> Result { + self.send(&self.default, encode(msg)).await + } + + /// Get recipient by address + 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 + pub async fn send (&self, to: &ChatPeerId, msg: S) -> Result + where S: Into + Debug{ + Ok(self.tg.execute( + SendMessage::new(*to, msg) + .with_parse_mode(MarkdownV2) + ).await?) + } + + /// Send media to specified user + 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 { + let mut caption = InputMediaDocument::default(); + if pos == 1 { + caption = caption.with_caption(msg) + .with_caption_parse_mode(MarkdownV2); + } + pos -= 1; + attach.push( + MediaGroupItem::for_document( + InputFile::from( + InputFileReader::from(file.data) + .with_file_name(file.name) + ), + caption + ) + ); + } + self.tg.execute(SendMediaGroup::new(*to, MediaGroup::new(attach)?)).await?; + } else { + self.tg.execute( + SendDocument::new( + *to, + InputFileReader::from(media[0].data.clone()) + .with_file_name(media[0].name.clone()) + ).with_caption(msg) + .with_caption_parse_mode(MarkdownV2) + ).await?; + } + Ok(()) + } +} ADDED src/tests.rs Index: src/tests.rs ================================================================== --- /dev/null +++ src/tests.rs @@ -0,0 +1,7 @@ +use crate::telegram::encode; + +#[test] +fn check_regex () { + let res = encode("-_*[]()~`>#+|{}.!"); + assert_eq!(res, "\\-\\_\\*\\[\\]\\(\\)\\~\\`\\>\\#\\+\\|\\{\\}\\.\\!"); +} ADDED src/utils.rs Index: src/utils.rs ================================================================== --- /dev/null +++ src/utils.rs @@ -0,0 +1,16 @@ +use crate::Cursor; + +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + pub static ref RE_SPECIAL: Regex = Regex::new(r"([\-_*\[\]()~`>#+|{}\.!])").unwrap(); + 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])?)*$").unwrap(); +} + +/// `Attachment` object to store number attachment data and corresponding file name +#[derive(Debug)] +pub struct Attachment { + pub data: Cursor>, + pub name: String, +}