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