Lines of
src/mail.rs
from check-in e678fcdc39
that are changed by the sequence of edits moving toward
check-in 1984831c3f:
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: pub fn new (settings: config::Config) -> Result<MailServer> {
74: let api_key = settings.get_string("api_key")
75: .context("[smtp2tg.toml] missing \"api_key\" parameter.\n")?;
76: let mut recipients = HashMap::new();
77: for (name, value) in settings.get_table("recipients")
78: .context("[smtp2tg.toml] missing table \"recipients\".\n")?
79: {
80: let value = value.into_int()
81: .context("[smtp2tg.toml] \"recipient\" table values should be integers.\n")?;
82: recipients.insert(name.to_lowercase(), value);
83: }
84:
85: let tg = Arc::new(TelegramTransport::new(api_key, recipients, &settings)?);
86: let fields = HashSet::<String>::from_iter(settings.get_array("fields")
87: .context("[smtp2tg.toml] \"fields\" should be an array")?
88: .iter().map(|x| x.clone().into_string().context("should be strings"))
89: .collect::<Result<Vec<String>>>()?);
90: let mut domains: HashSet<String> = HashSet::new();
91: let extra_domains = settings.get_array("domains").stack()?;
92: for domain in extra_domains {
93: let domain = domain.to_string().to_lowercase();
94: if RE_DOMAIN.is_match(&domain) {
95: domains.insert(domain);
96: } else {
97: bail!("Invalid domain in configuration: '{domain}'\n\
98: Domains must be valid (e.g., 'example.com', 'localhost').\n\
99: Check 'domains' array in smtp2tg.toml.");
100: } }
101: if domains.is_empty() {
102: bail!("No domains, need at least one: default `localhost` would do.");
103: }
104: let re_domains = domains.iter().map(|s| escape(s))
105: .collect::<Vec<String>>().join("|");
106: let address = RegexBuilder::new(&format!("^[a-z0-9][a-z0-9.-]*(@({re_domains}))?$"))
107: .case_insensitive(true).build().stack()?;
108:
109: Ok(MailServer {
e678fcdc39 2026-09-12 110: data: vec!(),
111: headers: None,
112: tg,
113: fields,
114: address,
115: })
116: }
117:
118: /// Retrieves the Telegram chat ID for a given email address, checks that
119: /// used domain is allowed.
120: ///
121: /// # Arguments
122: /// * `name` - Email address or username to look up.
123: ///
124: /// # Returns
125: /// * `Result<ChatPeerId>` - Telegram chat ID for the address, or default if
126: /// not found.
127: pub fn get_id (&self, name: &str) -> Result<&ChatPeerId> {
128: if self.address.is_match(name) {
129: Ok(self.tg.get(name).unwrap_or(&self.tg.default))
130: } else {
131: bail!("Email address {name:?} is not from an allowed domain.");
132: } }
133:
134: /// Attempt to deliver one message
135: async fn relay_mail (&self) -> Result<()> {
136: if let Some(headers) = &self.headers {
137: let mail = mail_parser::MessageParser::new().parse(&self.data)
138: .context("Failed to parse mail.")?;
139:
140: // Adding all known addresses to recipient list, for anyone else adding default
141: // Also if list is empty also adding default
142: let mut rcpt: HashSet<&ChatPeerId> = HashSet::new();
143: if headers.to.is_empty() {
144: bail!("Relaying is disabled, and there's no destination address");
145: }
146: for item in &headers.to {
147: rcpt.insert(self.get_id(item)?);
148: };
149: if rcpt.is_empty() {
150: self.tg.debug("No recipient or envelope address.").await?;
151: rcpt.insert(&self.tg.default);
152: };
153:
154: // preparing message header
155: let mut reply: Vec<String> = vec!["<blockquote expandable>".into()];
156: if self.fields.contains("subject") {
157: if let Some(subject) = mail.subject() {
158: reply.push(format!("<u><i>Subject:</i></u> <code>{}</code>", validate(subject).stack()?));
159: } else if let Some(thread) = mail.thread_name() {
160: reply.push(format!("<u><i>Thread:</i></u> <code>{}</code>", validate(thread).stack()?));
161: }
162: }
163: // do we need to replace spaces here?
164: if self.fields.contains("from") {
165: reply.push(format!("<u><i>From:</i></u> <code>{}</code>", validate(&headers.from).stack()?));
166: }
167: if self.fields.contains("date")
168: && let Some(date) = mail.date()
169: {
170: reply.push(format!("<u><i>Date:</i></u> <code>{date}</code>"));
171: }
172: reply.push("</blockquote><pre>".into());
173: let reply = reply.join("\n");
174:
e678fcdc39 2026-09-12 175: let html_parts = mail.html_body_count();
e678fcdc39 2026-09-12 176: let text_parts = mail.text_body_count();
e678fcdc39 2026-09-12 177: let attachments = mail.attachment_count();
178: if html_parts != text_parts {
179: self.tg.debug(&format!("Hm, we have {html_parts} HTML parts and {text_parts} text parts.")).await?;
180: }
181: //let mut html_num = 0;
182: let mut text_num = 0;
183: let mut file_num = 0;
184: // let's display first html or text part as body
185: let mut body: String = "".into();
186: /*
187: * actually I don't wanna parse that html stuff
188: if html_parts > 0 {
189: let text = mail.body_html(0).stack()?;
190: if text.len() < 4096 - header_size {
191: body = text;
192: html_num = 1;
193: }
194: };
195: */
196: if body.is_empty() && text_parts > 0 {
197: let text = mail.body_text(0)
198: .context("Failed to extract text from message")?
199: .replace("\r\n", "\n");
200: let text = validate(&text).stack()?;
201: // 6:
202: // - (headers)
203: // - (mail text)
204: // - 6: </pre>
205: if text.len() < 4096 - ( reply.len() + 6 ) {
206: body = text.to_string();
207: text_num = 1;
208: }
209: };
210: let msg = format!("{}{}</pre>", reply, body);
211:
212: // and let's collect all other attachment parts
213: let mut files_to_send = vec![];
214: /*
215: * let's just skip html parts for now, they just duplicate text?
216: while html_num < html_parts {
217: files_to_send.push(mail.html_part(html_num).stack()?);
218: html_num += 1;
219: }
220: */
221: while text_num < text_parts {
e678fcdc39 2026-09-12 222: files_to_send.push(mail.text_part(text_num.try_into().stack()?)
223: .context("Failed to get text part from message.")?);
224: text_num += 1;
225: }
226: while file_num < attachments {
e678fcdc39 2026-09-12 227: files_to_send.push(mail.attachment(file_num.try_into().stack()?)
228: .context("Failed to get file part from message.")?);
229: file_num += 1;
230: }
231:
232: for chat in rcpt {
233: if !files_to_send.is_empty() {
234: let mut files = vec![];
235: // let mut first_one = true;
236: for chunk in &files_to_send {
e678fcdc39 2026-09-12 237: let data: Vec<u8> = chunk.contents().to_vec();
238: let mut filename: Option<String> = None;
239: for header in chunk.headers() {
e678fcdc39 2026-09-12 240: if header.name() == "Content-Type" {
241: match header.value() {
242: mail_parser::HeaderValue::ContentType(contenttype) => {
243: if let Some(fname) = contenttype.attribute("name") {
244: filename = Some(fname.to_owned());
245: }
246: },
247: _ => {
248: self.tg.debug("Attachment has bad ContentType header.").await?;
249: },
250: };
251: };
252: };
253: let filename = if let Some(fname) = filename {
254: fname
255: } else {
256: "Attachment.txt".into()
257: };
258: files.push(Attachment {
259: data: Cursor::new(data),
260: name: filename,
261: });
262: }
263: self.tg.sendgroup(chat, files, &msg).await?;
264: } else {
265: self.tg.send(chat, &msg).await?;
266: }
267: }
268: } else {
269: bail!("Required headers were not found.");
270: }
271: Ok(())
272: }
273: }
274:
275: /// SMTP handler implementation for mailin-embedded.
276: impl mailin_embedded::Handler for MailServer {
277: /// Just deny login auth
278: fn auth_login (&mut self, _username: &str, _password: &str) -> Response {
279: INVALID_CREDENTIALS
280: }
281:
282: /// Just deny plain auth
283: fn auth_plain (&mut self, _authorization_id: &str, _authentication_id: &str, _password: &str) -> Response {
284: INVALID_CREDENTIALS
285: }
286:
287: /// Verify whether address is deliverable
288: fn rcpt (&mut self, to: &str) -> Response {
289: if self.get_id(to).is_ok() {
290: OK
291: } else {
292: NO_MAILBOX
293: }
294: }
295:
296: /// Save headers we need
297: fn data_start (&mut self, _domain: &str, from: &str, _is8bit: bool, to: &[String]) -> Response {
298: self.headers = Some(SomeHeaders{
299: from: from.to_string(),
300: to: to.to_vec(),
301: });
302: OK
303: }
304:
305: /// Save chunk(?) of data
306: fn data (&mut self, buf: &[u8]) -> std::result::Result<(), Error> {
307: self.data.append(buf.to_vec().as_mut());
308: Ok(())
309: }
310:
311: /// Attempt to send email, return temporary error if that fails
312: fn data_end (&mut self) -> Response {
313: let mut result = OK;
314: smol::block_on(Compat::new(async {
315: // relay mail
316: if let Err(err) = self.relay_mail().await {
317: result = INTERNAL_ERROR;
318: // in case that fails - inform default recipient
319: if let Err(err) = self.tg.debug(&format!("Sending emails failed:\n{err:}")).await {
320: // in case that also fails - write some logs and bail
321: eprintln!("{err:?}");
322: };
323: };
324: }));
325: // clear - just in case
326: self.data = vec![];
327: self.headers = None;
328: result
329: }
330: }