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
# 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
# - deny: drop them
unknown = "relay"

# 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
default = 0




[recipients]
# make sure you quote emails, as "@" can't go there unquoted. And by default
# we need FQDNs
"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










<
|
<
<
|
<
<







|

>
>
>



|





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"


# default hostname to use when serving requests


hostname = "smtp2tg"



# 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 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, 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
/// # 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:?}");

	};
	{
		let meta = metadata(config_file).await.stack()?;
		if (!0o100600 & meta.permissions().mode()) > 0 {



			bail!("other users can read or write config file {config_file:?}\n\
				File permissions: {:o}", 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"))?;



	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







|
>




>
>
>
|
|
|
<










|
>
|
>
>







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!("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\
				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!(
			"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
#[derive(Clone, Debug)]
pub struct MailServer {
	data: Vec<u8>,
	headers: Option<SomeHeaders>,
	tg: Arc<TelegramTransport>,
	fields: HashSet<String>,
	address: Regex,

}

impl MailServer {
	/// Initializes the mail server: sets up the Telegram API client and
	/// validates all required configuration values.
	///
	/// # Arguments







>







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
		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!("[smtp2tg.toml] can't check domains in \"domains\": {domain}");
			}
		}
		if domains.is_empty() {
			bail!("No domains, need at least one: default `localhost` would do.");
		}
		let domains = domains.into_iter().map(|s| escape(&s))
			.collect::<Vec<String>>().join("|");
		let address = RegexBuilder::new(&format!("^[a-z0-9][a-z0-9.-]*(@({domains}))?$"))
			.case_insensitive(true).build().stack()?;

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

		})
	}

	/// 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.");
		}
	}

	/// 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
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\
					Check 'domains' array in smtp2tg.toml.");
		}	}

		if domains.is_empty() {
			bail!("No domains, need at least one: default `localhost` would do.");
		}
		let re_domains = domains.iter().map(|s| escape(s))
			.collect::<Vec<String>>().join("|");
		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!("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
	/// # 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"))
	}

	/// Sends a text message to a specified chat.
	///
	/// # Arguments
	/// * `to` - Target chat ID.
	/// * `msg` - Message text (supports HTML formatting).







|







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"))
	}

	/// 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
/// # 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.");
	} else {
		Ok(encode_text(text))
	}
}







|


|
<
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!("Text contains a Telegram closing tag (e.g., </pre>, </code>).");
	} else {
		Ok(encode_text(text))
}	}