512369f93e 2026-08-01 1: //! SMTP server implementation for receiving and processing emails.
512369f93e 2026-08-01 2: //!
512369f93e 2026-08-01 3: //! This module handles SMTP connections, email parsing, and forwarding to
512369f93e 2026-08-01 4: //! Telegram.
512369f93e 2026-08-01 5:
f5ed284f8c 2025-06-21 6: use crate::{
f5ed284f8c 2025-06-21 7: Cursor,
0f47e23e21 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,
0f47e23e21 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,
158c9cffc6 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 {
1723b63d69 2026-08-01 65: /// Initializes the mail server: sets up the Telegram API client and
1723b63d69 2026-08-01 66: /// validates all required configuration values.
512369f93e 2026-08-01 67: ///
1723b63d69 2026-08-01 68: /// # Arguments
1723b63d69 2026-08-01 69: /// * `settings` - Parsed application configuration.
512369f93e 2026-08-01 70: ///
512369f93e 2026-08-01 71: /// # Errors
ec55d3e69e 2026-10-28 72: /// Returns an error if required configuration values are missing or invalid,
ec55d3e69e 2026-10-28 73: /// Telegram API client creation fails, or the address regex cannot be built.
1723b63d69 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")
98c5a42df0 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")?;
aaa78fed23 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")
98c5a42df0 2026-08-01 88: .context("[smtp2tg.toml] \"fields\" should be an array")?
98c5a42df0 2026-08-01 89: .iter().map(|x| x.clone().into_string().context("should be strings"))
98c5a42df0 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 {
0acb6536ae 2026-09-10 98: bail!("Invalid domain in configuration: '{domain}'\n\
0acb6536ae 2026-09-10 99: Domains must be valid (e.g., 'example.com', 'localhost').\n\
0acb6536ae 2026-09-10 100: Check 'domains' array in smtp2tg.toml.");
0acb6536ae 2026-09-10 101: } }
98c5a42df0 2026-08-01 102: if domains.is_empty() {
98c5a42df0 2026-08-01 103: bail!("No domains, need at least one: default `localhost` would do.");
f5ed284f8c 2025-06-21 104: }
0acb6536ae 2026-09-10 105: let re_domains = domains.iter().map(|s| escape(s))
f5ed284f8c 2025-06-21 106: .collect::<Vec<String>>().join("|");
0acb6536ae 2026-09-10 107: let address = RegexBuilder::new(&format!("^[a-z0-9][a-z0-9.-]*(@({re_domains}))?$"))
158c9cffc6 2026-08-01 108: .case_insensitive(true).build().stack()?;
f5ed284f8c 2025-06-21 109:
f5ed284f8c 2025-06-21 110: Ok(MailServer {
1984831c3f 2026-10-25 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:
512369f93e 2026-08-01 119: /// Retrieves the Telegram chat ID for a given email address, checks that
512369f93e 2026-08-01 120: /// used domain is allowed.
512369f93e 2026-08-01 121: ///
512369f93e 2026-08-01 122: /// # Arguments
1723b63d69 2026-08-01 123: /// * `name` - Email address or username to look up.
512369f93e 2026-08-01 124: ///
512369f93e 2026-08-01 125: /// # Returns
512369f93e 2026-08-01 126: /// * `Result<ChatPeerId>` - Telegram chat ID for the address, or default if
512369f93e 2026-08-01 127: /// not found.
f748e99b70 2026-08-01 128: pub fn get_id (&self, name: &str) -> Result<&ChatPeerId> {
f748e99b70 2026-08-01 129: if self.address.is_match(name) {
1723b63d69 2026-08-01 130: Ok(self.tg.get(name).unwrap_or(&self.tg.default))
f748e99b70 2026-08-01 131: } else {
0acb6536ae 2026-09-10 132: bail!("Email address {name:?} is not from an allowed domain.");
0acb6536ae 2026-09-10 133: } }
f748e99b70 2026-08-01 134:
ec55d3e69e 2026-10-28 135: /// Forwards the buffered email to each distinct destination chat, using the
ec55d3e69e 2026-10-28 136: /// default chat for allowed addresses without a configured recipient.
ec55d3e69e 2026-10-28 137: ///
ec55d3e69e 2026-10-28 138: /// Inlines the first plain-text body if the escaped message is below 4096
ec55d3e69e 2026-10-28 139: /// bytes. Sends remaining text parts and decoded attachments as documents;
ec55d3e69e 2026-10-28 140: /// HTML body parts are omitted. Leaves the buffered data and envelope intact.
ec55d3e69e 2026-10-28 141: ///
ec55d3e69e 2026-10-28 142: /// # Errors
ec55d3e69e 2026-10-28 143: /// Returns an error for a missing envelope, no recipients, a disallowed
ec55d3e69e 2026-10-28 144: /// address, mail parsing or part extraction failures, or rejected closing
ec55d3e69e 2026-10-28 145: /// `pre`/`code` tags in text passed to validation. Propagates media-group
ec55d3e69e 2026-10-28 146: /// construction and Telegram request errors, including diagnostic sends.
ec55d3e69e 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();
158c9cffc6 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:
0f47e23e21 2026-01-12 167: // preparing message header
0f47e23e21 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() {
0f47e23e21 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() {
0f47e23e21 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") {
0f47e23e21 2026-01-12 178: reply.push(format!("<u><i>From:</i></u> <code>{}</code>", validate(&headers.from).stack()?));
0f47e23e21 2026-01-12 179: }
0f47e23e21 2026-01-12 180: if self.fields.contains("date")
0f47e23e21 2026-01-12 181: && let Some(date) = mail.date()
0f47e23e21 2026-01-12 182: {
0f47e23e21 2026-01-12 183: reply.push(format!("<u><i>Date:</i></u> <code>{date}</code>"));
0f47e23e21 2026-01-12 184: }
0f47e23e21 2026-01-12 185: reply.push("</blockquote><pre>".into());
c996f5c871 2026-01-12 186: let reply = reply.join("\n");
c996f5c871 2026-01-12 187:
1984831c3f 2026-10-25 188: let text_parts = mail.text_body().len();
1984831c3f 2026-10-25 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;
42ac4c7a9a 2026-10-28 194: // Display the first text part as the body if it fits.
42ac4c7a9a 2026-10-28 195: let mut body = String::new();
42ac4c7a9a 2026-10-28 196: if text_parts > 0 {
f5ed284f8c 2025-06-21 197: let text = mail.body_text(0)
c996f5c871 2026-01-12 198: .context("Failed to extract text from message")?
c996f5c871 2026-01-12 199: .replace("\r\n", "\n");
db04f7359b 2026-01-18 200: let text = validate(&text).stack()?;
c996f5c871 2026-01-12 201: // 6:
c996f5c871 2026-01-12 202: // - (headers)
0f47e23e21 2026-01-12 203: // - (mail text)
0f47e23e21 2026-01-12 204: // - 6: </pre>
6ce625a569 2026-01-12 205: if text.len() < 4096 - ( reply.len() + 6 ) {
42ac4c7a9a 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: };
db04f7359b 2026-01-18 210: let msg = format!("{}{}</pre>", reply, body);
db04f7359b 2026-01-18 211:
42ac4c7a9a 2026-10-28 212: // Send remaining text-body parts followed by attachments.
42ac4c7a9a 2026-10-28 213: let files_to_send: Vec<_> = mail.text_body().skip(text_num)
42ac4c7a9a 2026-10-28 214: .chain(mail.attachments()).collect();
c996f5c871 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 {
1984831c3f 2026-10-25 220: let data: Vec<u8> = chunk.decoded().to_vec();
f5ed284f8c 2025-06-21 221: let mut filename: Option<String> = None;
42ac4c7a9a 2026-10-28 222: for header in chunk.headers().iter()
42ac4c7a9a 2026-10-28 223: .filter(|header| header.raw_name() == "Content-Type")
42ac4c7a9a 2026-10-28 224: {
42ac4c7a9a 2026-10-28 225: match header.value() {
42ac4c7a9a 2026-10-28 226: mail_parser::HeaderValue::ContentType(contenttype) => {
42ac4c7a9a 2026-10-28 227: if let Some(fname) = contenttype.attribute("name") {
42ac4c7a9a 2026-10-28 228: filename = Some(fname.to_owned());
42ac4c7a9a 2026-10-28 229: }
42ac4c7a9a 2026-10-28 230: },
42ac4c7a9a 2026-10-28 231: _ => {
42ac4c7a9a 2026-10-28 232: self.tg.debug("Attachment has bad ContentType header.").await?;
42ac4c7a9a 2026-10-28 233: },
42ac4c7a9a 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),
42ac4c7a9a 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:
512369f93e 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 {
158c9cffc6 2026-08-01 267: if self.get_id(to).is_ok() {
f5ed284f8c 2025-06-21 268: OK
f5ed284f8c 2025-06-21 269: } else {
1723b63d69 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
700254f838 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: }