Index: Cargo.lock ================================================================== --- Cargo.lock +++ Cargo.lock @@ -525,11 +525,11 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "event-listener" version = "2.5.3" @@ -1122,11 +1122,11 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1598,11 +1598,11 @@ dependencies = [ "bitflags 2.8.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "rustls" version = "0.21.12" @@ -1853,19 +1853,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "smtp2tg" -version = "0.3.4" +version = "0.3.5" dependencies = [ "anyhow", "async-std", "config", "just-getopt", + "lazy_static", "mail-parser", "mailin-embedded", + "regex", "teloxide", + "thiserror 2.0.11", ] [[package]] name = "socket2" version = "0.5.8" @@ -1988,11 +1991,11 @@ "mime", "pin-project", "serde", "serde_json", "teloxide-core", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-stream", "tokio-util", "url", ] @@ -2018,11 +2021,11 @@ "serde", "serde_json", "serde_with", "take_mut", "takecell", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-util", "url", "uuid", "vecrem", @@ -2037,11 +2040,11 @@ "cfg-if", "fastrand", "getrandom", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "ternop" version = "1.0.1" @@ -2052,11 +2055,20 @@ name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +dependencies = [ + "thiserror-impl 2.0.11", ] [[package]] name = "thiserror-impl" version = "1.0.69" @@ -2065,10 +2077,21 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.96", ] + +[[package]] +name = "thiserror-impl" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] [[package]] name = "tinystr" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" Index: Cargo.toml ================================================================== --- Cargo.toml +++ Cargo.toml @@ -1,17 +1,20 @@ [package] name = "smtp2tg" -version = "0.3.4" +version = "0.3.5" authors = [ "arcade" ] edition = "2021" [dependencies] anyhow = "1.0.86" async-std = { version = "1.12.0", features = [ "attributes", "tokio1" ] } config = { version = "=0.14.0", default-features = false, features = [ "toml" ] } # Rust 1.75 just-getopt = "1.2.0" +lazy_static = "1.5.0" +regex = "1.11.1" teloxide = { version = "0.13", features = [ "rustls", "throttle" ] } +thiserror = "2.0.11" mail-parser = { version = "0.9.3", features = ["serde", "serde_support"] } mailin-embedded = "^0" [profile.release] lto = true Index: src/main.rs ================================================================== --- src/main.rs +++ src/main.rs @@ -1,14 +1,10 @@ //! 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::{ - anyhow, - bail, - Result, -}; +use anyhow::Result; use async_std::{ fs::metadata, io::Error, task, }; @@ -15,14 +11,16 @@ use just_getopt::{ OptFlags, OptSpecs, OptValueType, }; +use lazy_static::lazy_static; use mailin_embedded::{ Response, response::*, }; +use regex::Regex; use teloxide::{ Bot, prelude::{ Requester, RequesterExt, @@ -32,10 +30,11 @@ InputMedia, Message, ParseMode::MarkdownV2, }, }; +use thiserror::Error; use std::{ borrow::Cow, collections::{ HashMap, @@ -43,10 +42,26 @@ }, 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] teloxide::RequestError), +} /// `SomeHeaders` object to store data through SMTP session #[derive(Clone, Debug)] struct SomeHeaders { from: String, @@ -61,10 +76,30 @@ recipients: HashMap, relay: bool, tg: teloxide::adaptors::DefaultParseMode>, fields: HashSet, } + +lazy_static! { + static ref RE_SPECIAL: Regex = Regex::new(r"([\-_*\[\]()~`>#+|{}\.!])").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 = Bot::new(settings.get_string("api_key") @@ -110,76 +145,77 @@ fields, } } /// Send message to default user, used for debug/log/info purposes - async fn debug<'b, S>(&self, msg: S) -> Result - where S: Into { - Ok(self.tg.send_message(*self.recipients.get("_").unwrap(), msg).await?) + async fn debug <'a, S> (&self, msg: S) -> Result + where S: Into<&'a str> { + let msg = msg.into(); + Ok(self.tg.send_message(*self.recipients.get("_").ok_or(MyError::NoDefault)?, encode(msg)).await?) } /// Send message to specified user - async fn send<'b, S>(&self, to: &ChatId, msg: S) -> Result + async fn send (&self, to: &ChatId, msg: S) -> Result where S: Into { Ok(self.tg.send_message(*to, msg).await?) } /// Attempt to deliver one message - async fn relay_mail (&self) -> Result<()> { + async fn relay_mail (&self) -> Result<(), MyError> { if let Some(headers) = &self.headers { let mail = mail_parser::MessageParser::new().parse(&self.data) - .ok_or(anyhow!("Failed to parse mail"))?; + .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<&ChatId> = HashSet::new(); if headers.to.is_empty() { - bail!("No recipient addresses."); + return Err(MyError::NoRecipient); } for item in &headers.to { match self.recipients.get(item) { Some(addr) => rcpt.insert(addr), None => { - self.debug(format!("Recipient [{}] not found\\.", &item)).await?; + self.debug(&*format!("Recipient [{}] not found.", &item)).await?; rcpt.insert(self.recipients.get("_") - .ok_or(anyhow!("Missing default address in recipient table\\."))?) + .ok_or(MyError::NoDefault)?) } }; }; if rcpt.is_empty() { - self.debug("No recipient or envelope address\\.").await?; + self.debug("No recipient or envelope address.").await?; rcpt.insert(self.recipients.get("_") - .ok_or(anyhow!("Missing default address in recipient table."))?); + .ok_or(MyError::NoDefault)?); }; // prepating message header - let mut reply: Vec> = vec![]; + let mut reply: Vec = vec![]; if self.fields.contains("subject") { if let Some(subject) = mail.subject() { - reply.push(format!("__*Subject:*__ `{}`", subject).into()); + reply.push(format!("__*Subject:*__ `{}`", encode(subject))); } else if let Some(thread) = mail.thread_name() { - reply.push(format!("__*Thread:*__ `{}`", thread).into()); + reply.push(format!("__*Thread:*__ `{}`", encode(thread))); } } - let mut short_headers: Vec> = vec![]; + let mut short_headers: Vec = vec![]; // do we need to replace spaces here? if self.fields.contains("from") { - short_headers.push(format!("__*From:*__ `{}`", headers.from).into()); + 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).into()); + short_headers.push(format!("__*Date:*__ `{}`", date)); } } - reply.push(short_headers.join(" ").into()); + 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 and {} text parts\\.", html_parts, text_parts)).await?; + self.debug(&*format!("Hm, we have {} HTML parts and {} text parts.", html_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 @@ -194,11 +230,11 @@ } }; */ if body == "" && text_parts > 0 { let text = mail.body_text(0) - .ok_or(anyhow!("Failed to extract text from message."))?; + .ok_or(MyError::NoText)?; if text.len() < 4096 - header_size { body = text; text_num = 1; } }; @@ -215,16 +251,16 @@ html_num += 1; } */ while text_num < text_parts { files_to_send.push(mail.text_part(text_num) - .ok_or(anyhow!("Failed to get text part from message"))?); + .ok_or(MyError::NoText)?); text_num += 1; } while file_num < attachments { files_to_send.push(mail.attachment(file_num) - .ok_or(anyhow!("Failed to get file part from message"))?); + .ok_or(MyError::NoText)?); file_num += 1; } let msg = reply.join("\n"); for chat in rcpt { @@ -241,11 +277,11 @@ if let Some(fname) = contenttype.attribute("name") { filename = Some(fname.to_owned()); } }, _ => { - self.debug("Attachment has bad ContentType header\\.").await?; + self.debug("Attachment has bad ContentType header.").await?; }, }; }; }; let filename = if let Some(fname) = filename { @@ -256,11 +292,11 @@ let item = teloxide::types::InputMediaDocument::new( teloxide::types::InputFile::memory(data.to_vec()) .file_name(filename)); let item = if first_one { first_one = false; - item.caption(&msg).parse_mode(MarkdownV2) + item.caption(&msg) } else { item }; files.push(InputMedia::Document(item)); } @@ -268,17 +304,17 @@ } else { self.send(chat, &msg).await?; } } } else { - bail!("No headers."); + return Err(MyError::NoHeaders); } Ok(()) } /// Send media to specified user - pub async fn sendgroup(&self, to: &ChatId, media: M) -> Result> + pub async fn sendgroup (&self, to: &ChatId, media: M) -> Result, MyError> where M: IntoIterator { Ok(self.tg.send_media_group(*to, media).await?) } } @@ -319,26 +355,26 @@ }); OK } /// Save chunk(?) of data - fn data(&mut self, buf: &[u8]) -> Result<(), Error> { + 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 { + 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 { + if let Err(err) = self.debug(&*format!("Sending emails failed:\n{}", err)).await { // in case that also fails - write some logs and bail - eprintln!("Failed to contact Telegram:\n{:?}", err); + eprintln!("{:?}", err); }; }; }); // clear - just in case self.data = vec![]; @@ -346,11 +382,11 @@ result } } #[async_std::main] -async fn main() -> Result<()> { +async fn main () -> Result<()> { let specs = OptSpecs::new() .option("help", "h", OptValueType::None) .option("help", "help", OptValueType::None) .option("config", "c", OptValueType::Required) .option("config", "config", OptValueType::Required)