Annotation For src/mail.rs
Logged in as anonymous

Origin for each line in src/mail.rs from check-in 9fecd57a99:

78b11b0319 2026-08-01    1: //! SMTP server implementation for receiving and processing emails.
78b11b0319 2026-08-01    2: //!
78b11b0319 2026-08-01    3: //! This module handles SMTP connections, email parsing, and forwarding to
78b11b0319 2026-08-01    4: //! Telegram.
78b11b0319 2026-08-01    5: 
f5ed284f8c 2025-06-21    6: use crate::{
f5ed284f8c 2025-06-21    7: 	Cursor,
d87e80b9be 2026-01-12    8: 	telegram::TelegramTransport,
f5ed284f8c 2025-06-21    9: 	utils::{
f5ed284f8c 2025-06-21   10: 		Attachment,
f5ed284f8c 2025-06-21   11: 		RE_DOMAIN,
d87e80b9be 2026-01-12   12: 		validate,
f5ed284f8c 2025-06-21   13: 	},
f5ed284f8c 2025-06-21   14: };
f5ed284f8c 2025-06-21   15: 
f5ed284f8c 2025-06-21   16: use std::{
f5ed284f8c 2025-06-21   17: 	collections::{
f5ed284f8c 2025-06-21   18: 		HashMap,
f5ed284f8c 2025-06-21   19: 		HashSet,
f5ed284f8c 2025-06-21   20: 	},
f5ed284f8c 2025-06-21   21: 	io::Error,
f5ed284f8c 2025-06-21   22: 	sync::Arc,
f5ed284f8c 2025-06-21   23: };
072229b5bf 2026-01-01   24: 
14ef340959 2026-01-01   25: use async_compat::Compat;
f5ed284f8c 2025-06-21   26: use mailin_embedded::{
f5ed284f8c 2025-06-21   27: 	Response,
f5ed284f8c 2025-06-21   28: 	response::{
f5ed284f8c 2025-06-21   29: 		INTERNAL_ERROR,
f5ed284f8c 2025-06-21   30: 		INVALID_CREDENTIALS,
f5ed284f8c 2025-06-21   31: 		NO_MAILBOX,
f5ed284f8c 2025-06-21   32: 		OK
f5ed284f8c 2025-06-21   33: 	},
f5ed284f8c 2025-06-21   34: };
f5ed284f8c 2025-06-21   35: use regex::{
f5ed284f8c 2025-06-21   36: 	Regex,
78b11b0319 2026-08-01   37: 	RegexBuilder,
f5ed284f8c 2025-06-21   38: 	escape,
a044f68fa7 2025-08-23   39: };
a044f68fa7 2025-08-23   40: use stacked_errors::{
a044f68fa7 2025-08-23   41: 	Result,
a044f68fa7 2025-08-23   42: 	StackableErr,
a044f68fa7 2025-08-23   43: 	bail,
f5ed284f8c 2025-06-21   44: };
f5ed284f8c 2025-06-21   45: use tgbot::types::ChatPeerId;
f5ed284f8c 2025-06-21   46: 
f5ed284f8c 2025-06-21   47: /// `SomeHeaders` object to store data through SMTP session
f5ed284f8c 2025-06-21   48: #[derive(Clone, Debug)]
f5ed284f8c 2025-06-21   49: struct SomeHeaders {
f5ed284f8c 2025-06-21   50: 	from: String,
f5ed284f8c 2025-06-21   51: 	to: Vec<String>,
f5ed284f8c 2025-06-21   52: }
f5ed284f8c 2025-06-21   53: 
f5ed284f8c 2025-06-21   54: /// `MailServer` Central object with TG api and configuration
f5ed284f8c 2025-06-21   55: #[derive(Clone, Debug)]
f5ed284f8c 2025-06-21   56: pub struct MailServer {
f5ed284f8c 2025-06-21   57: 	data: Vec<u8>,
f5ed284f8c 2025-06-21   58: 	headers: Option<SomeHeaders>,
f5ed284f8c 2025-06-21   59: 	tg: Arc<TelegramTransport>,
f5ed284f8c 2025-06-21   60: 	fields: HashSet<String>,
f5ed284f8c 2025-06-21   61: 	address: Regex,
f5ed284f8c 2025-06-21   62: }
f5ed284f8c 2025-06-21   63: 
f5ed284f8c 2025-06-21   64: impl MailServer {
78b11b0319 2026-08-01   65: 	/// Initializes the mail server: sets up the Telegram API client and
78b11b0319 2026-08-01   66: 	/// validates all required configuration values.
78b11b0319 2026-08-01   67: 	///
78b11b0319 2026-08-01   68: 	/// # Arguments
78b11b0319 2026-08-01   69: 	/// * `settings` - Parsed application configuration.
78b11b0319 2026-08-01   70: 	///
78b11b0319 2026-08-01   71: 	/// # Errors
9fecd57a99 2026-10-28   72: 	/// Returns an error if required configuration values are missing or invalid,
9fecd57a99 2026-10-28   73: 	/// Telegram API client creation fails, or the address regex cannot be built.
78b11b0319 2026-08-01   74: 	pub fn new (settings: config::Config) -> Result<MailServer> {
f5ed284f8c 2025-06-21   75: 		let api_key = settings.get_string("api_key")
f5ed284f8c 2025-06-21   76: 			.context("[smtp2tg.toml] missing \"api_key\" parameter.\n")?;
f5ed284f8c 2025-06-21   77: 		let mut recipients = HashMap::new();
f5ed284f8c 2025-06-21   78: 		for (name, value) in settings.get_table("recipients")
78b11b0319 2026-08-01   79: 			.context("[smtp2tg.toml] missing table \"recipients\".\n")?
f5ed284f8c 2025-06-21   80: 		{
f5ed284f8c 2025-06-21   81: 			let value = value.into_int()
f5ed284f8c 2025-06-21   82: 				.context("[smtp2tg.toml] \"recipient\" table values should be integers.\n")?;
78b11b0319 2026-08-01   83: 			recipients.insert(name.to_lowercase(), value);
14ef340959 2026-01-01   84: 		}
14ef340959 2026-01-01   85: 
14ef340959 2026-01-01   86: 		let tg = Arc::new(TelegramTransport::new(api_key, recipients, &settings)?);
14ef340959 2026-01-01   87: 		let fields = HashSet::<String>::from_iter(settings.get_array("fields")
78b11b0319 2026-08-01   88: 			.context("[smtp2tg.toml] \"fields\" should be an array")?
78b11b0319 2026-08-01   89: 			.iter().map(|x| x.clone().into_string().context("should be strings"))
78b11b0319 2026-08-01   90: 			.collect::<Result<Vec<String>>>()?);
f5ed284f8c 2025-06-21   91: 		let mut domains: HashSet<String> = HashSet::new();
a044f68fa7 2025-08-23   92: 		let extra_domains = settings.get_array("domains").stack()?;
f5ed284f8c 2025-06-21   93: 		for domain in extra_domains {
f5ed284f8c 2025-06-21   94: 			let domain = domain.to_string().to_lowercase();
f5ed284f8c 2025-06-21   95: 			if RE_DOMAIN.is_match(&domain) {
f5ed284f8c 2025-06-21   96: 				domains.insert(domain);
f5ed284f8c 2025-06-21   97: 			} else {
42867194d6 2026-09-13   98: 				bail!("Invalid domain in configuration: '{domain}'\n\
42867194d6 2026-09-13   99: 					Domains must be valid (e.g., 'example.com', 'localhost').\n\
42867194d6 2026-09-13  100: 					Check 'domains' array in smtp2tg.toml.");
42867194d6 2026-09-13  101: 		}	}
78b11b0319 2026-08-01  102: 		if domains.is_empty() {
78b11b0319 2026-08-01  103: 			bail!("No domains, need at least one: default `localhost` would do.");
f5ed284f8c 2025-06-21  104: 		}
42867194d6 2026-09-13  105: 		let re_domains = domains.iter().map(|s| escape(s))
f5ed284f8c 2025-06-21  106: 			.collect::<Vec<String>>().join("|");
42867194d6 2026-09-13  107: 		let address = RegexBuilder::new(&format!("^[a-z0-9][a-z0-9.-]*(@({re_domains}))?$"))
78b11b0319 2026-08-01  108: 			.case_insensitive(true).build().stack()?;
f5ed284f8c 2025-06-21  109: 
f5ed284f8c 2025-06-21  110: 		Ok(MailServer {
9fecd57a99 2026-10-28  111: 			data: vec![],
f5ed284f8c 2025-06-21  112: 			headers: None,
f5ed284f8c 2025-06-21  113: 			tg,
f5ed284f8c 2025-06-21  114: 			fields,
f5ed284f8c 2025-06-21  115: 			address,
f5ed284f8c 2025-06-21  116: 		})
f5ed284f8c 2025-06-21  117: 	}
f5ed284f8c 2025-06-21  118: 
78b11b0319 2026-08-01  119: 	/// Retrieves the Telegram chat ID for a given email address, checks that
78b11b0319 2026-08-01  120: 	/// used domain is allowed.
78b11b0319 2026-08-01  121: 	///
78b11b0319 2026-08-01  122: 	/// # Arguments
78b11b0319 2026-08-01  123: 	/// * `name` - Email address or username to look up.
78b11b0319 2026-08-01  124: 	///
78b11b0319 2026-08-01  125: 	/// # Returns
78b11b0319 2026-08-01  126: 	/// * `Result<ChatPeerId>` - Telegram chat ID for the address, or default if
78b11b0319 2026-08-01  127: 	///   not found.
78b11b0319 2026-08-01  128: 	pub fn get_id (&self, name: &str) -> Result<&ChatPeerId> {
78b11b0319 2026-08-01  129: 		if self.address.is_match(name) {
78b11b0319 2026-08-01  130: 			Ok(self.tg.get(name).unwrap_or(&self.tg.default))
78b11b0319 2026-08-01  131: 		} else {
42867194d6 2026-09-13  132: 			bail!("Email address {name:?} is not from an allowed domain.");
42867194d6 2026-09-13  133: 	}	}
78b11b0319 2026-08-01  134: 
9fecd57a99 2026-10-28  135: 	/// Forwards the buffered email to each distinct destination chat, using the
9fecd57a99 2026-10-28  136: 	/// default chat for allowed addresses without a configured recipient.
9fecd57a99 2026-10-28  137: 	///
9fecd57a99 2026-10-28  138: 	/// Inlines the first plain-text body if the escaped message is below 4096
9fecd57a99 2026-10-28  139: 	/// bytes. Sends remaining text parts and decoded attachments as documents;
9fecd57a99 2026-10-28  140: 	/// HTML body parts are omitted. Leaves the buffered data and envelope intact.
9fecd57a99 2026-10-28  141: 	///
9fecd57a99 2026-10-28  142: 	/// # Errors
9fecd57a99 2026-10-28  143: 	/// Returns an error for a missing envelope, no recipients, a disallowed
9fecd57a99 2026-10-28  144: 	/// address, mail parsing or part extraction failures, or rejected closing
9fecd57a99 2026-10-28  145: 	/// `pre`/`code` tags in text passed to validation. Propagates media-group
9fecd57a99 2026-10-28  146: 	/// construction and Telegram request errors, including diagnostic sends.
9fecd57a99 2026-10-28  147: 	/// Delivery to earlier chats is not rolled back if a later send fails.
f5ed284f8c 2025-06-21  148: 	async fn relay_mail (&self) -> Result<()> {
f5ed284f8c 2025-06-21  149: 		if let Some(headers) = &self.headers {
f5ed284f8c 2025-06-21  150: 			let mail = mail_parser::MessageParser::new().parse(&self.data)
f5ed284f8c 2025-06-21  151: 				.context("Failed to parse mail.")?;
f5ed284f8c 2025-06-21  152: 
f5ed284f8c 2025-06-21  153: 			// Adding all known addresses to recipient list, for anyone else adding default
f5ed284f8c 2025-06-21  154: 			// Also if list is empty also adding default
f5ed284f8c 2025-06-21  155: 			let mut rcpt: HashSet<&ChatPeerId> = HashSet::new();
78b11b0319 2026-08-01  156: 			if headers.to.is_empty() {
f5ed284f8c 2025-06-21  157: 				bail!("Relaying is disabled, and there's no destination address");
f5ed284f8c 2025-06-21  158: 			}
f5ed284f8c 2025-06-21  159: 			for item in &headers.to {
f5ed284f8c 2025-06-21  160: 				rcpt.insert(self.get_id(item)?);
f5ed284f8c 2025-06-21  161: 			};
f5ed284f8c 2025-06-21  162: 			if rcpt.is_empty() {
f5ed284f8c 2025-06-21  163: 				self.tg.debug("No recipient or envelope address.").await?;
f5ed284f8c 2025-06-21  164: 				rcpt.insert(&self.tg.default);
f5ed284f8c 2025-06-21  165: 			};
f5ed284f8c 2025-06-21  166: 
d87e80b9be 2026-01-12  167: 			// preparing message header
d87e80b9be 2026-01-12  168: 			let mut reply: Vec<String> = vec!["<blockquote expandable>".into()];
f5ed284f8c 2025-06-21  169: 			if self.fields.contains("subject") {
f5ed284f8c 2025-06-21  170: 				if let Some(subject) = mail.subject() {
d87e80b9be 2026-01-12  171: 					reply.push(format!("<u><i>Subject:</i></u> <code>{}</code>", validate(subject).stack()?));
f5ed284f8c 2025-06-21  172: 				} else if let Some(thread) = mail.thread_name() {
d87e80b9be 2026-01-12  173: 					reply.push(format!("<u><i>Thread:</i></u> <code>{}</code>", validate(thread).stack()?));
f5ed284f8c 2025-06-21  174: 				}
f5ed284f8c 2025-06-21  175: 			}
f5ed284f8c 2025-06-21  176: 			// do we need to replace spaces here?
f5ed284f8c 2025-06-21  177: 			if self.fields.contains("from") {
d87e80b9be 2026-01-12  178: 				reply.push(format!("<u><i>From:</i></u> <code>{}</code>", validate(&headers.from).stack()?));
d87e80b9be 2026-01-12  179: 			}
d87e80b9be 2026-01-12  180: 			if self.fields.contains("date")
d87e80b9be 2026-01-12  181: 				&& let Some(date) = mail.date()
d87e80b9be 2026-01-12  182: 			{
d87e80b9be 2026-01-12  183: 				reply.push(format!("<u><i>Date:</i></u> <code>{date}</code>"));
d87e80b9be 2026-01-12  184: 			}
d87e80b9be 2026-01-12  185: 			reply.push("</blockquote><pre>".into());
d87e80b9be 2026-01-12  186: 			let reply = reply.join("\n");
d87e80b9be 2026-01-12  187: 
9fecd57a99 2026-10-28  188: 			let text_parts = mail.text_body().len();
9fecd57a99 2026-10-28  189: 			let html_parts = mail.html_body().len();
f5ed284f8c 2025-06-21  190: 			if html_parts != text_parts {
f5ed284f8c 2025-06-21  191: 				self.tg.debug(&format!("Hm, we have {html_parts} HTML parts and {text_parts} text parts.")).await?;
f5ed284f8c 2025-06-21  192: 			}
f5ed284f8c 2025-06-21  193: 			let mut text_num = 0;
9fecd57a99 2026-10-28  194: 			// Display the first text part as the body if it fits.
9fecd57a99 2026-10-28  195: 			let mut body = String::new();
9fecd57a99 2026-10-28  196: 			if text_parts > 0 {
f5ed284f8c 2025-06-21  197: 				let text = mail.body_text(0)
d87e80b9be 2026-01-12  198: 					.context("Failed to extract text from message")?
d87e80b9be 2026-01-12  199: 					.replace("\r\n", "\n");
1c10bccb4e 2026-01-18  200: 				let text = validate(&text).stack()?;
d87e80b9be 2026-01-12  201: 				// 6:
d87e80b9be 2026-01-12  202: 				// - (headers)
d87e80b9be 2026-01-12  203: 				// - (mail text)
d87e80b9be 2026-01-12  204: 				// - 6: </pre>
d87e80b9be 2026-01-12  205: 				if text.len() < 4096 - ( reply.len() + 6 ) {
9fecd57a99 2026-10-28  206: 					body = text.into_owned();
f5ed284f8c 2025-06-21  207: 					text_num = 1;
f5ed284f8c 2025-06-21  208: 				}
f5ed284f8c 2025-06-21  209: 			};
1c10bccb4e 2026-01-18  210: 			let msg = format!("{}{}</pre>", reply, body);
1c10bccb4e 2026-01-18  211: 
9fecd57a99 2026-10-28  212: 			// Send remaining text-body parts followed by attachments.
9fecd57a99 2026-10-28  213: 			let files_to_send: Vec<_> = mail.text_body().skip(text_num)
9fecd57a99 2026-10-28  214: 				.chain(mail.attachments()).collect();
d87e80b9be 2026-01-12  215: 
f5ed284f8c 2025-06-21  216: 			for chat in rcpt {
f5ed284f8c 2025-06-21  217: 				if !files_to_send.is_empty() {
f5ed284f8c 2025-06-21  218: 					let mut files = vec![];
f5ed284f8c 2025-06-21  219: 					for chunk in &files_to_send {
9fecd57a99 2026-10-28  220: 						let data: Vec<u8> = chunk.decoded().to_vec();
f5ed284f8c 2025-06-21  221: 						let mut filename: Option<String> = None;
9fecd57a99 2026-10-28  222: 						for header in chunk.headers().iter()
9fecd57a99 2026-10-28  223: 							.filter(|header| header.raw_name().eq_ignore_ascii_case("Content-Type"))
9fecd57a99 2026-10-28  224: 						{
9fecd57a99 2026-10-28  225: 							match header.value() {
9fecd57a99 2026-10-28  226: 								mail_parser::HeaderValue::ContentType(contenttype) => {
9fecd57a99 2026-10-28  227: 									if let Some(fname) = contenttype.attribute("name") {
9fecd57a99 2026-10-28  228: 										filename = Some(fname.to_owned());
9fecd57a99 2026-10-28  229: 									}
9fecd57a99 2026-10-28  230: 								},
9fecd57a99 2026-10-28  231: 								_ => {
9fecd57a99 2026-10-28  232: 									self.tg.debug("Attachment has bad ContentType header.").await?;
9fecd57a99 2026-10-28  233: 								},
9fecd57a99 2026-10-28  234: 							};
f5ed284f8c 2025-06-21  235: 						};
f5ed284f8c 2025-06-21  236: 						files.push(Attachment {
f5ed284f8c 2025-06-21  237: 							data: Cursor::new(data),
9fecd57a99 2026-10-28  238: 							name: filename.unwrap_or_else(|| "Attachment.txt".into()),
f5ed284f8c 2025-06-21  239: 						});
f5ed284f8c 2025-06-21  240: 					}
f5ed284f8c 2025-06-21  241: 					self.tg.sendgroup(chat, files, &msg).await?;
f5ed284f8c 2025-06-21  242: 				} else {
f5ed284f8c 2025-06-21  243: 					self.tg.send(chat, &msg).await?;
f5ed284f8c 2025-06-21  244: 				}
f5ed284f8c 2025-06-21  245: 			}
f5ed284f8c 2025-06-21  246: 		} else {
f5ed284f8c 2025-06-21  247: 			bail!("Required headers were not found.");
f5ed284f8c 2025-06-21  248: 		}
f5ed284f8c 2025-06-21  249: 		Ok(())
f5ed284f8c 2025-06-21  250: 	}
f5ed284f8c 2025-06-21  251: }
f5ed284f8c 2025-06-21  252: 
78b11b0319 2026-08-01  253: /// SMTP handler implementation for mailin-embedded.
f5ed284f8c 2025-06-21  254: impl mailin_embedded::Handler for MailServer {
f5ed284f8c 2025-06-21  255: 	/// Just deny login auth
f5ed284f8c 2025-06-21  256: 	fn auth_login (&mut self, _username: &str, _password: &str) -> Response {
f5ed284f8c 2025-06-21  257: 		INVALID_CREDENTIALS
f5ed284f8c 2025-06-21  258: 	}
f5ed284f8c 2025-06-21  259: 
f5ed284f8c 2025-06-21  260: 	/// Just deny plain auth
f5ed284f8c 2025-06-21  261: 	fn auth_plain (&mut self, _authorization_id: &str, _authentication_id: &str, _password: &str) -> Response {
f5ed284f8c 2025-06-21  262: 		INVALID_CREDENTIALS
f5ed284f8c 2025-06-21  263: 	}
f5ed284f8c 2025-06-21  264: 
f5ed284f8c 2025-06-21  265: 	/// Verify whether address is deliverable
f5ed284f8c 2025-06-21  266: 	fn rcpt (&mut self, to: &str) -> Response {
78b11b0319 2026-08-01  267: 		if self.get_id(to).is_ok() {
f5ed284f8c 2025-06-21  268: 			OK
f5ed284f8c 2025-06-21  269: 		} else {
78b11b0319 2026-08-01  270: 			NO_MAILBOX
f5ed284f8c 2025-06-21  271: 		}
f5ed284f8c 2025-06-21  272: 	}
f5ed284f8c 2025-06-21  273: 
f5ed284f8c 2025-06-21  274: 	/// Save headers we need
f5ed284f8c 2025-06-21  275: 	fn data_start (&mut self, _domain: &str, from: &str, _is8bit: bool, to: &[String]) -> Response {
f5ed284f8c 2025-06-21  276: 		self.headers = Some(SomeHeaders{
f5ed284f8c 2025-06-21  277: 			from: from.to_string(),
f5ed284f8c 2025-06-21  278: 			to: to.to_vec(),
f5ed284f8c 2025-06-21  279: 		});
f5ed284f8c 2025-06-21  280: 		OK
f5ed284f8c 2025-06-21  281: 	}
f5ed284f8c 2025-06-21  282: 
f5ed284f8c 2025-06-21  283: 	/// Save chunk(?) of data
a044f68fa7 2025-08-23  284: 	fn data (&mut self, buf: &[u8]) -> std::result::Result<(), Error> {
f5ed284f8c 2025-06-21  285: 		self.data.append(buf.to_vec().as_mut());
f5ed284f8c 2025-06-21  286: 		Ok(())
f5ed284f8c 2025-06-21  287: 	}
f5ed284f8c 2025-06-21  288: 
f5ed284f8c 2025-06-21  289: 	/// Attempt to send email, return temporary error if that fails
f5ed284f8c 2025-06-21  290: 	fn data_end (&mut self) -> Response {
f5ed284f8c 2025-06-21  291: 		let mut result = OK;
14ef340959 2026-01-01  292: 		smol::block_on(Compat::new(async {
f5ed284f8c 2025-06-21  293: 			// relay mail
f5ed284f8c 2025-06-21  294: 			if let Err(err) = self.relay_mail().await {
f5ed284f8c 2025-06-21  295: 				result = INTERNAL_ERROR;
f5ed284f8c 2025-06-21  296: 				// in case that fails - inform default recipient
1c10bccb4e 2026-01-18  297: 				if let Err(err) = self.tg.debug(&format!("Sending emails failed:\n{err:}")).await {
f5ed284f8c 2025-06-21  298: 					// in case that also fails - write some logs and bail
f5ed284f8c 2025-06-21  299: 					eprintln!("{err:?}");
f5ed284f8c 2025-06-21  300: 				};
f5ed284f8c 2025-06-21  301: 			};
14ef340959 2026-01-01  302: 		}));
f5ed284f8c 2025-06-21  303: 		// clear - just in case
f5ed284f8c 2025-06-21  304: 		self.data = vec![];
f5ed284f8c 2025-06-21  305: 		self.headers = None;
f5ed284f8c 2025-06-21  306: 		result
f5ed284f8c 2025-06-21  307: 	}
f5ed284f8c 2025-06-21  308: }