Index: Cargo.lock ================================================================== --- Cargo.lock +++ Cargo.lock @@ -701,10 +701,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ "windows-sys 0.59.0", ] + +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] [[package]] name = "http" version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1016,11 +1027,11 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.0", + "windows-targets 0.53.1", ] [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1659,15 +1670,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smtp2tg" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "async-std", "config", + "hostname", "just-getopt", "lazy_static", "mail-parser", "mailin-embedded", "regex", @@ -2148,10 +2160,16 @@ "home", "once_cell", "rustix 0.38.44", ] +[[package]] +name = "windows-link" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3bfe459f85da17560875b8bf1423d6f113b7a87a5d942e7da0ac71be7c61f8b" + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" @@ -2184,13 +2202,13 @@ "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows-targets" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +checksum = "30357ec391cde730f8fbfcdc29adc47518b06504528df977ab5af02ef23fdee9" 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", Index: Cargo.toml ================================================================== --- Cargo.toml +++ Cargo.toml @@ -1,15 +1,16 @@ [package] name = "smtp2tg" -version = "0.4.0" +version = "0.4.1" authors = [ "arcade" ] edition = "2021" [dependencies] anyhow = "1.0.86" async-std = { version = "1.12.0", features = [ "attributes", "tokio1" ] } 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" Index: smtp2tg.toml.example ================================================================== --- smtp2tg.toml.example +++ smtp2tg.toml.example @@ -1,20 +1,29 @@ # 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" + # whether we need to handle unknown adresses # - relay: send them to default one # - deny: drop them unknown = "relay" + # default fields to show in message header fields = [ "date", "from", "subject" ] +# 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 + # 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 Index: src/main.rs ================================================================== --- src/main.rs +++ src/main.rs @@ -16,11 +16,14 @@ use lazy_static::lazy_static; use mailin_embedded::{ Response, response::*, }; -use regex::Regex; +use regex::{ + Regex, + escape, +}; use tgbot::{ api::Client, types::{ ChatPeerId, InputFile, @@ -90,14 +93,16 @@ 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") @@ -131,10 +136,23 @@ } 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, @@ -155,10 +173,11 @@ 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 { @@ -171,10 +190,28 @@ 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) @@ -185,18 +222,11 @@ let mut rcpt: HashSet<&ChatPeerId> = HashSet::new(); if headers.to.is_empty() { return Err(MyError::NoRecipient); } for item in &headers.to { - match self.recipients.get(item) { - Some(addr) => rcpt.insert(addr), - None => { - self.debug(&format!("Recipient [{item}] not found.")).await?; - rcpt.insert(self.recipients.get("_") - .ok_or(MyError::NoDefault)?) - } - }; + 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)?); @@ -371,13 +401,13 @@ /// Verify whether address is deliverable fn rcpt (&mut self, to: &str) -> Response { if self.relay { OK } else { - match self.recipients.get(to) { - Some(_) => OK, - None => { + match self.get_id(to) { + Ok(_) => OK, + Err(_) => { if self.relay { OK } else { NO_MAILBOX } @@ -463,10 +493,11 @@ let settings: config::Config = config::Config::builder() .set_default("fields", vec!["date", "from", "subject"]).unwrap() .set_default("hostname", "smtp.2.tg").unwrap() .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"));