Check-in [0acb6536ae]
Logged in as anonymous
Overview
Comment:remove `unknown`, expand output and logs
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: 0acb6536aec8d481fcebf9f3685de260be025eca782f2daffce800ffb7506600
User & Date: arcade on 2026-09-10 07:06:19.036
Other Links: manifest | tags
Context
2026-09-10
07:07
bump check-in: cb1fdeff6f user: arcade tags: trunk
07:06
remove `unknown`, expand output and logs check-in: 0acb6536ae user: arcade tags: trunk
2026-08-01
19:32
and fix tests check-in: 40a93e9a58 user: arcade tags: trunk
Changes
1
2
3
4
5
6
7
8
9
10
11
12

13
14
15

16
17
18
19
20
21
22
23
24
25

26



27
28
29
30

31
32
33
34
35
1
2
3
4
5
6
7
8
9
10


11



12


13
14
15
16
17
18
19

20
21
22
23
24
25
26
27

28
29
30
31
32
33










-
-
+
-
-
-
+
-
-







-
+

+
+
+



-
+





# vi:ft=toml:
# Telegram API key
api_key = "YOU_KNOW_WHERE_TO_GET_THIS"

# Telegram API gateway (when you are running your own)
api_gateway = "https://api.telegram.org" # <- note no trailing slash

# where to listen on (sockets are not supported since 0.3.0)
listen_on = "0.0.0.0:25"

# whether we need to handle unknown adresses
# - relay: send them to default one
# default hostname to use when serving requests
# - deny: drop them
unknown = "relay"

hostname = "smtp2tg"
# default fields to show in message header
fields = [ "date", "from", "subject" ]

# which domains are allowed in addresses
# this means that any unqualified recipient "somebody" will also match
# to "somebody@each_domain"
domains = [ "localhost", "current.hostname" ]

# default recipient, should be specified
# still can be a user, channel or group
# still can be a user, channel or group id
default = 0

# default fields to show in message header
fields = [ "date", "from", "subject" ]

[recipients]
# make sure you quote emails, as "@" can't go there unquoted. And by default
# we need FQDNs
# we need FQDNs, also keep in mind emails are case insensitive
"somebody@example.com" = 1 # user id's are positive
"root" = -1 # group id's are negative

# to look up chat/group id you can use debug settings in Telegram clients,
# or some bot like @getidsbot or @RawDataBot
47
48
49
50
51
52
53
54


55
56
57
58



59
60
61



62
63
64
65
66
67
68
69
70
71
72
73
74





75
76
77
78
79
80
81
47
48
49
50
51
52
53

54
55
56
57
58
59
60
61
62



63
64
65

66
67
68
69
70
71
72
73
74
75


76
77
78
79
80
81
82
83
84
85
86
87







-
+
+




+
+
+
-
-
-
+
+
+
-










-
-
+
+
+
+
+







/// # Errors
/// Returns an error if configuration is invalid, files are inaccessible, or
/// server fails to start.
pub async fn async_main () -> Result<()> {
	let args = Args::parse();
	let config_file = Path::new(&args.config);
	if !config_file.exists() {
		bail!("can't read configuration from {config_file:?}");
		bail!("Configuration file not found: {config_file:?}\n\
			Hint: Ensure the file exists and the path is correct.");
	};
	{
		let meta = metadata(config_file).await.stack()?;
		if (!0o100600 & meta.permissions().mode()) > 0 {
			bail!("Configuration file permissions are insecure {config_file:?}\n\
				Current permissions: {:o}\n\
				Required: 0600 (owner read/write only).\n\
			bail!("other users can read or write config file {config_file:?}\n\
				File permissions: {:o}", meta.permissions().mode());
		}
				Fix with: chmod 600 {config_file:?}",
				meta.permissions().mode());
	}	}
	}
	let settings: config::Config = config::Config::builder()
		.set_default("api_gateway", "https://api.telegram.org").stack()?
		.set_default("fields", vec!["date", "from", "subject"]).stack()?
		.set_default("hostname", "smtp.2.tg").stack()?
		.set_default("listen_on", "0.0.0.0:1025").stack()?
		.set_default("domains", vec!["localhost",
			hostname::get().context("Failed to get current hostname")?
			.to_str().context("Can't convert hostname to string, bad UTF-8?")?]).stack()?
		.add_source(config::File::from(config_file))
		.build()
		.with_context(|| format!("[{config_file:?}] there was an error reading config\n\
			\tplease consult \"smtp2tg.toml.example\" for details"))?;
		.with_context(|| format!(
			"Failed to parse configuration file: {config_file:?}\n\
			Check syntax against smtp2tg.toml.example.\n\
			Common issues: missing quotes, trailing commas, or invalid types."
		))?;

	let listen_on = settings.get_string("listen_on").stack()?;
	let server_name = settings.get_string("hostname").stack()?;
	let core = MailServer::new(settings)?;
	let mut server = mailin_embedded::Server::new(core);

	// TODO: remove unwraps when mailin-embedded bumps with better error handling
55
56
57
58
59
60
61

62
63
64
65
66
67
68
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69







+







#[derive(Clone, Debug)]
pub struct MailServer {
	data: Vec<u8>,
	headers: Option<SomeHeaders>,
	tg: Arc<TelegramTransport>,
	fields: HashSet<String>,
	address: Regex,
	domains: HashSet<String>,
}

impl MailServer {
	/// Initializes the mail server: sets up the Telegram API client and
	/// validates all required configuration values.
	///
	/// # Arguments
91
92
93
94
95
96
97


98
99


100
101
102
103
104

105
106

107
108
109
110
111
112
113
114

115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132


133
134
135
136
137
138
139
140
92
93
94
95
96
97
98
99
100


101
102

103
104
105

106
107

108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133


134
135

136
137
138
139
140
141
142







+
+
-
-
+
+
-



-
+

-
+








+
















-
-
+
+
-







		let mut domains: HashSet<String> = HashSet::new();
		let extra_domains = settings.get_array("domains").stack()?;
		for domain in extra_domains {
			let domain = domain.to_string().to_lowercase();
			if RE_DOMAIN.is_match(&domain) {
				domains.insert(domain);
			} else {
				bail!("Invalid domain in configuration: '{domain}'\n\
					Domains must be valid (e.g., 'example.com', 'localhost').\n\
				bail!("[smtp2tg.toml] can't check domains in \"domains\": {domain}");
			}
					Check 'domains' array in smtp2tg.toml.");
		}	}
		}
		if domains.is_empty() {
			bail!("No domains, need at least one: default `localhost` would do.");
		}
		let domains = domains.into_iter().map(|s| escape(&s))
		let re_domains = domains.iter().map(|s| escape(s))
			.collect::<Vec<String>>().join("|");
		let address = RegexBuilder::new(&format!("^[a-z0-9][a-z0-9.-]*(@({domains}))?$"))
		let address = RegexBuilder::new(&format!("^[a-z0-9][a-z0-9.-]*(@({re_domains}))?$"))
			.case_insensitive(true).build().stack()?;

		Ok(MailServer {
			data: vec!(),
			headers: None,
			tg,
			fields,
			address,
			domains,
		})
	}

	/// Retrieves the Telegram chat ID for a given email address, checks that
	/// used domain is allowed.
	///
	/// # Arguments
	/// * `name` - Email address or username to look up.
	///
	/// # Returns
	/// * `Result<ChatPeerId>` - Telegram chat ID for the address, or default if
	///   not found.
	pub fn get_id (&self, name: &str) -> Result<&ChatPeerId> {
		if self.address.is_match(name) {
			Ok(self.tg.get(name).unwrap_or(&self.tg.default))
		} else {
			bail!("Doesn't look like address from one of our domains.");
		}
			bail!("Email address {name:?} is not from an allowed domain.");
	}	}
	}

	/// Attempt to deliver one message
	async fn relay_mail (&self) -> Result<()> {
		if let Some(headers) = &self.headers {
			let mail = mail_parser::MessageParser::new().parse(&self.data)
				.context("Failed to parse mail.")?;

92
93
94
95
96
97
98
99

100
101
102
103
104
105
106
92
93
94
95
96
97
98

99
100
101
102
103
104
105
106







-
+







	/// # Returns
	/// * `Result<&ChatPeerId>` - Chat ID if found.
	///
	/// # Errors
	/// Returns an error if `name` is not configured.
	pub fn get (&self, name: &str) -> Result<&ChatPeerId> {
		self.recipients.get(&name.to_lowercase())
			.with_context(|| format!("Recipient \"{name}\" not found in configuration"))
			.with_context(|| format!("Recipient {name:?} not found in configuration"))
	}

	/// Sends a text message to a specified chat.
	///
	/// # Arguments
	/// * `to` - Target chat ID.
	/// * `msg` - Message text (supports HTML formatting).
44
45
46
47
48
49
50
51

52
53
54

55
44
45
46
47
48
49
50

51
52
53

54








-
+


-
+
-
/// # Returns
/// * `Result<Cow<'a, str>>` - Escaped text or error if invalid.
///
/// # Errors
/// Returns an error if the text contains Telegram closing tags (`</pre>`, `</code>`).
pub fn validate <'a>(text: &'a str) -> Result<Cow<'a, str>> {
	if RE_CLOSING.is_match(text) {
		bail!("Telegram closing tag found.");
		bail!("Text contains a Telegram closing tag (e.g., </pre>, </code>).");
	} else {
		Ok(encode_text(text))
	}
}	}
}