Lines of
src/main.rs
from check-in d96b1b4710
that are changed by the sequence of edits moving toward
check-in 2158b44929:
1: //! Simple SMTP-to-Telegram gateway. Can parse email and send them as telegram
2: //! messages to specified chats, generally you specify which email address is
3: //! available in configuration, everything else is sent to default address.
4:
5: use anyhow::Result;
6: use async_std::{
7: fs::metadata,
8: io::Error,
9: task,
10: };
11: use just_getopt::{
12: OptFlags,
13: OptSpecs,
14: OptValue,
15: };
16: use lazy_static::lazy_static;
17: use mailin_embedded::{
18: Response,
19: response::*,
20: };
d96b1b4710 2025-06-11 21: use regex::Regex;
22: use tgbot::{
23: api::Client,
24: types::{
25: ChatPeerId,
26: InputFile,
27: InputFileReader,
28: InputMediaDocument,
29: MediaGroup,
30: MediaGroupItem,
31: Message,
32: ParseMode::MarkdownV2,
33: SendDocument,
34: SendMediaGroup,
35: SendMessage,
36: },
37: };
38: use thiserror::Error;
39:
40: use std::{
41: borrow::Cow,
42: collections::{
43: HashMap,
44: HashSet,
45: },
46: io::Cursor,
47: os::unix::fs::PermissionsExt,
48: path::Path,
49: vec::Vec,
50: };
51:
52: #[derive(Error, Debug)]
53: pub enum MyError {
54: #[error("Failed to parse mail")]
55: BadMail,
56: #[error("Missing default address in recipient table")]
57: NoDefault,
58: #[error("No headers found")]
59: NoHeaders,
60: #[error("No recipient addresses")]
61: NoRecipient,
62: #[error("Failed to extract text from message")]
63: NoText,
64: #[error(transparent)]
65: RequestError(#[from] tgbot::api::ExecuteError),
66: #[error(transparent)]
67: TryFromIntError(#[from] std::num::TryFromIntError),
68: #[error(transparent)]
69: InputMediaError(#[from] tgbot::types::InputMediaError),
70: #[error(transparent)]
71: MediaGroupError(#[from] tgbot::types::MediaGroupError),
72: }
73:
74: /// `SomeHeaders` object to store data through SMTP session
75: #[derive(Clone, Debug)]
76: struct SomeHeaders {
77: from: String,
78: to: Vec<String>,
79: }
80:
81: struct Attachment {
82: data: Cursor<Vec<u8>>,
83: name: String,
84: }
85:
86: /// `TelegramTransport` Central object with TG api and configuration
87: #[derive(Clone)]
88: struct TelegramTransport {
89: data: Vec<u8>,
90: headers: Option<SomeHeaders>,
91: recipients: HashMap<String, ChatPeerId>,
92: relay: bool,
93: tg: Client,
94: fields: HashSet<String>,
95: }
96:
97: lazy_static! {
98: static ref RE_SPECIAL: Regex = Regex::new(r"([\-_*\[\]()~`>#+|{}\.!])").unwrap();
99: }
100:
101: /// Encodes special HTML entities to prevent them interfering with Telegram HTML
102: fn encode (text: &str) -> Cow<'_, str> {
103: RE_SPECIAL.replace_all(text, "\\$1")
104: }
105:
106: #[cfg(test)]
107: mod tests {
108: use crate::encode;
109:
110: #[test]
111: fn check_regex () {
112: let res = encode("-_*[]()~`>#+|{}.!");
113: assert_eq!(res, "\\-\\_\\*\\[\\]\\(\\)\\~\\`\\>\\#\\+\\|\\{\\}\\.\\!");
114: }
115: }
116:
117: impl TelegramTransport {
118: /// Initialize API and read configuration
119: fn new(settings: config::Config) -> TelegramTransport {
120: let tg = Client::new(settings.get_string("api_key")
121: .expect("[smtp2tg.toml] missing \"api_key\" parameter.\n"))
122: .expect("Failed to create API.\n");
123: let recipients: HashMap<String, ChatPeerId> = settings.get_table("recipients")
124: .expect("[smtp2tg.toml] missing table \"recipients\".\n")
125: .into_iter().map(|(a, b)| (a, ChatPeerId::from(b.into_int()
126: .expect("[smtp2tg.toml] \"recipient\" table values should be integers.\n")
127: ))).collect();
128: if !recipients.contains_key("_") {
129: eprintln!("[smtp2tg.toml] \"recipient\" table misses \"default_recipient\".\n");
130: panic!("no default recipient");
131: }
132: let fields = HashSet::<String>::from_iter(settings.get_array("fields")
133: .expect("[smtp2tg.toml] \"fields\" should be an array")
134: .iter().map(|x| x.clone().into_string().expect("should be strings")));
135: let value = settings.get_string("unknown");
136: let relay = match value {
137: Ok(value) => {
138: match value.as_str() {
139: "relay" => true,
140: "deny" => false,
141: _ => {
142: eprintln!("[smtp2tg.toml] \"unknown\" should be either \"relay\" or \"deny\".\n");
143: panic!("bad setting");
144: },
145: }
146: },
147: Err(err) => {
148: eprintln!("[smtp2tg.toml] can't get \"unknown\":\n {err:?}\n");
149: panic!("bad setting");
150: },
151: };
152:
153: TelegramTransport {
154: data: vec!(),
155: headers: None,
156: recipients,
157: relay,
158: tg,
159: fields,
160: }
161: }
162:
163: /// Send message to default user, used for debug/log/info purposes
164: async fn debug (&self, msg: &str) -> Result<Message, MyError> {
165: self.send(self.recipients.get("_").ok_or(MyError::NoDefault)?, encode(msg)).await
166: }
167:
168: /// Send message to specified user
169: async fn send <S> (&self, to: &ChatPeerId, msg: S) -> Result<Message, MyError>
170: where S: Into<String> {
171: Ok(self.tg.execute(
172: SendMessage::new(*to, msg)
173: .with_parse_mode(MarkdownV2)
174: ).await?)
175: }
176:
177: /// Attempt to deliver one message
178: async fn relay_mail (&self) -> Result<(), MyError> {
179: if let Some(headers) = &self.headers {
180: let mail = mail_parser::MessageParser::new().parse(&self.data)
181: .ok_or(MyError::BadMail)?;
182:
183: // Adding all known addresses to recipient list, for anyone else adding default
184: // Also if list is empty also adding default
185: let mut rcpt: HashSet<&ChatPeerId> = HashSet::new();
186: if headers.to.is_empty() {
187: return Err(MyError::NoRecipient);
188: }
189: for item in &headers.to {
d96b1b4710 2025-06-11 190: match self.recipients.get(item) {
d96b1b4710 2025-06-11 191: Some(addr) => rcpt.insert(addr),
d96b1b4710 2025-06-11 192: None => {
d96b1b4710 2025-06-11 193: self.debug(&format!("Recipient [{item}] not found.")).await?;
d96b1b4710 2025-06-11 194: rcpt.insert(self.recipients.get("_")
d96b1b4710 2025-06-11 195: .ok_or(MyError::NoDefault)?)
d96b1b4710 2025-06-11 196: }
d96b1b4710 2025-06-11 197: };
198: };
199: if rcpt.is_empty() {
200: self.debug("No recipient or envelope address.").await?;
201: rcpt.insert(self.recipients.get("_")
202: .ok_or(MyError::NoDefault)?);
203: };
204:
205: // prepating message header
206: let mut reply: Vec<String> = vec![];
207: if self.fields.contains("subject") {
208: if let Some(subject) = mail.subject() {
209: reply.push(format!("__*Subject:*__ `{}`", encode(subject)));
210: } else if let Some(thread) = mail.thread_name() {
211: reply.push(format!("__*Thread:*__ `{}`", encode(thread)));
212: }
213: }
214: let mut short_headers: Vec<String> = vec![];
215: // do we need to replace spaces here?
216: if self.fields.contains("from") {
217: short_headers.push(format!("__*From:*__ `{}`", encode(&headers.from)));
218: }
219: if self.fields.contains("date") {
220: if let Some(date) = mail.date() {
221: short_headers.push(format!("__*Date:*__ `{date}`"));
222: }
223: }
224: reply.push(short_headers.join(" "));
225: let header_size = reply.join(" ").len() + 1;
226:
227: let html_parts = mail.html_body_count();
228: let text_parts = mail.text_body_count();
229: let attachments = mail.attachment_count();
230: if html_parts != text_parts {
231: self.debug(&format!("Hm, we have {html_parts} HTML parts and {text_parts} text parts.")).await?;
232: }
233: //let mut html_num = 0;
234: let mut text_num = 0;
235: let mut file_num = 0;
236: // let's display first html or text part as body
237: let mut body: Cow<'_, str> = "".into();
238: /*
239: * actually I don't wanna parse that html stuff
240: if html_parts > 0 {
241: let text = mail.body_html(0).unwrap();
242: if text.len() < 4096 - header_size {
243: body = text;
244: html_num = 1;
245: }
246: };
247: */
248: if body.is_empty() && text_parts > 0 {
249: let text = mail.body_text(0)
250: .ok_or(MyError::NoText)?;
251: if text.len() < 4096 - header_size {
252: body = text;
253: text_num = 1;
254: }
255: };
256: reply.push("```".into());
257: reply.extend(body.lines().map(|x| x.into()));
258: reply.push("```".into());
259:
260: // and let's collect all other attachment parts
261: let mut files_to_send = vec![];
262: /*
263: * let's just skip html parts for now, they just duplicate text?
264: while html_num < html_parts {
265: files_to_send.push(mail.html_part(html_num).unwrap());
266: html_num += 1;
267: }
268: */
269: while text_num < text_parts {
270: files_to_send.push(mail.text_part(text_num.try_into()?)
271: .ok_or(MyError::NoText)?);
272: text_num += 1;
273: }
274: while file_num < attachments {
275: files_to_send.push(mail.attachment(file_num.try_into()?)
276: .ok_or(MyError::NoText)?);
277: file_num += 1;
278: }
279:
280: let msg = reply.join("\n");
281: for chat in rcpt {
282: if !files_to_send.is_empty() {
283: let mut files = vec![];
284: // let mut first_one = true;
285: for chunk in &files_to_send {
286: let data: Vec<u8> = chunk.contents().to_vec();
287: let mut filename: Option<String> = None;
288: for header in chunk.headers() {
289: if header.name() == "Content-Type" {
290: match header.value() {
291: mail_parser::HeaderValue::ContentType(contenttype) => {
292: if let Some(fname) = contenttype.attribute("name") {
293: filename = Some(fname.to_owned());
294: }
295: },
296: _ => {
297: self.debug("Attachment has bad ContentType header.").await?;
298: },
299: };
300: };
301: };
302: let filename = if let Some(fname) = filename {
303: fname
304: } else {
305: "Attachment.txt".into()
306: };
307: files.push(Attachment {
308: data: Cursor::new(data),
309: name: filename,
310: });
311: }
312: self.sendgroup(chat, files, &msg).await?;
313: } else {
314: self.send(chat, &msg).await?;
315: }
316: }
317: } else {
318: return Err(MyError::NoHeaders);
319: }
320: Ok(())
321: }
322:
323: /// Send media to specified user
324: pub async fn sendgroup (&self, to: &ChatPeerId, media: Vec<Attachment>, msg: &str) -> Result<(), MyError> {
325: if media.len() > 1 {
326: let mut attach = vec![];
327: let mut pos = media.len();
328: for file in media {
329: let mut caption = InputMediaDocument::default();
330: if pos == 1 {
331: caption = caption.with_caption(msg)
332: .with_caption_parse_mode(MarkdownV2);
333: }
334: pos -= 1;
335: attach.push(
336: MediaGroupItem::for_document(
337: InputFile::from(
338: InputFileReader::from(file.data)
339: .with_file_name(file.name)
340: ),
341: caption
342: )
343: );
344: }
345: self.tg.execute(SendMediaGroup::new(*to, MediaGroup::new(attach)?)).await?;
346: } else {
347: self.tg.execute(
348: SendDocument::new(
349: *to,
350: InputFileReader::from(media[0].data.clone())
351: .with_file_name(media[0].name.clone())
352: ).with_caption(msg)
353: .with_caption_parse_mode(MarkdownV2)
354: ).await?;
355: }
356: Ok(())
357: }
358: }
359:
360: impl mailin_embedded::Handler for TelegramTransport {
361: /// Just deny login auth
362: fn auth_login (&mut self, _username: &str, _password: &str) -> Response {
363: INVALID_CREDENTIALS
364: }
365:
366: /// Just deny plain auth
367: fn auth_plain (&mut self, _authorization_id: &str, _authentication_id: &str, _password: &str) -> Response {
368: INVALID_CREDENTIALS
369: }
370:
371: /// Verify whether address is deliverable
372: fn rcpt (&mut self, to: &str) -> Response {
373: if self.relay {
374: OK
375: } else {
d96b1b4710 2025-06-11 376: match self.recipients.get(to) {
d96b1b4710 2025-06-11 377: Some(_) => OK,
d96b1b4710 2025-06-11 378: None => {
379: if self.relay {
380: OK
381: } else {
382: NO_MAILBOX
383: }
384: }
385: }
386: }
387: }
388:
389: /// Save headers we need
390: fn data_start (&mut self, _domain: &str, from: &str, _is8bit: bool, to: &[String]) -> Response {
391: self.headers = Some(SomeHeaders{
392: from: from.to_string(),
393: to: to.to_vec(),
394: });
395: OK
396: }
397:
398: /// Save chunk(?) of data
399: fn data (&mut self, buf: &[u8]) -> Result<(), Error> {
400: self.data.append(buf.to_vec().as_mut());
401: Ok(())
402: }
403:
404: /// Attempt to send email, return temporary error if that fails
405: fn data_end (&mut self) -> Response {
406: let mut result = OK;
407: task::block_on(async {
408: // relay mail
409: if let Err(err) = self.relay_mail().await {
410: result = INTERNAL_ERROR;
411: // in case that fails - inform default recipient
412: if let Err(err) = self.debug(&format!("Sending emails failed:\n{err:?}")).await {
413: // in case that also fails - write some logs and bail
414: eprintln!("{err:?}");
415: };
416: };
417: });
418: // clear - just in case
419: self.data = vec![];
420: self.headers = None;
421: result
422: }
423: }
424:
425: #[async_std::main]
426: async fn main () -> Result<()> {
427: let specs = OptSpecs::new()
428: .option("help", "h", OptValue::None)
429: .option("help", "help", OptValue::None)
430: .option("config", "c", OptValue::Required)
431: .option("config", "config", OptValue::Required)
432: .flag(OptFlags::OptionsEverywhere);
433: let mut args = std::env::args();
434: args.next();
435: let parsed = specs.getopt(args);
436: for u in &parsed.unknown {
437: println!("Unknown option: {u}");
438: }
439: if !(parsed.unknown.is_empty()) || parsed.options_first("help").is_some() {
440: println!("SMTP2TG v{}, (C) 2024 - 2025\n\n\
441: \t-h|--help\tDisplay this help\n\
442: \t-c|-config …\tSet configuration file location.",
443: env!("CARGO_PKG_VERSION"));
444: return Ok(());
445: };
446: let config_file = Path::new(if let Some(path) = parsed.options_value_last("config") {
447: &path[..]
448: } else {
449: "smtp2tg.toml"
450: });
451: if !config_file.exists() {
452: eprintln!("Error: can't read configuration from {config_file:?}");
453: std::process::exit(1);
454: };
455: {
456: let meta = metadata(config_file).await?;
457: if (!0o100600 & meta.permissions().mode()) > 0 {
458: eprintln!("Error: other users can read or write config file {config_file:?}\n\
459: File permissions: {:o}", meta.permissions().mode());
460: std::process::exit(1);
461: }
462: }
463: let settings: config::Config = config::Config::builder()
464: .set_default("fields", vec!["date", "from", "subject"]).unwrap()
465: .set_default("hostname", "smtp.2.tg").unwrap()
466: .set_default("listen_on", "0.0.0.0:1025").unwrap()
467: .set_default("unknown", "relay").unwrap()
468: .add_source(config::File::from(config_file))
469: .build()
470: .unwrap_or_else(|_| panic!("[{config_file:?}] there was an error reading config\n\
471: \tplease consult \"smtp2tg.toml.example\" for details"));
472:
473: let listen_on = settings.get_string("listen_on")?;
474: let server_name = settings.get_string("hostname")?;
475: let core = TelegramTransport::new(settings);
476: let mut server = mailin_embedded::Server::new(core);
477:
478: server.with_name(server_name)
479: .with_ssl(mailin_embedded::SslConfig::None).unwrap()
480: .with_addr(listen_on).unwrap();
481: server.serve().unwrap();
482:
483: Ok(())
484: }