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