Check-in [98c5a42df0]
Logged in as anonymous
Overview
Comment:ensure!() we don't panic but instead return Result whenever possible, convert error handling a little, add TODO for unwrap(), more sanity checks, simplify and expand testing
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: 98c5a42df066f1639688919741b16b894aaf254157120baef66cdc64a230608b
User & Date: arcade on 2026-08-01 18:47:58.519
Other Links: manifest | tags
Context
2026-08-01
19:29
drop dot removal (wrong), document lowercasing check-in: aaa78fed23 user: arcade tags: trunk
18:47
ensure!() we don't panic but instead return Result whenever possible, convert error handling a little, add TODO for unwrap(), more sanity checks, simplify and expand testing check-in: 98c5a42df0 user: arcade tags: trunk
15:30
fix regexp, get rid of relaying variants as they are actually noop after move to mailin check-in: 158c9cffc6 user: arcade tags: trunk
Changes
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
	}
	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().expect("Failed to get current hostname")
			.to_str().expect("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);


	server.with_name(server_name)
		.with_ssl(mailin_embedded::SslConfig::None).unwrap()
		.with_addr(listen_on).unwrap();
	server.serve().unwrap();

	Ok(())
}







|
|










>







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
88
	}
	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
	server.with_name(server_name)
		.with_ssl(mailin_embedded::SslConfig::None).unwrap()
		.with_addr(listen_on).unwrap();
	server.serve().unwrap();

	Ok(())
}
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89

90
91
92
93
94
95
96
97
98
99



100
101
102
103
104
105
106
	/// Returns an error if required configuration values are missing or invalid.
	/// server fails to start.
	pub fn new (settings: config::Config) -> Result<MailServer> {
		let api_key = settings.get_string("api_key")
			.context("[smtp2tg.toml] missing \"api_key\" parameter.\n")?;
		let mut recipients = HashMap::new();
		for (name, value) in settings.get_table("recipients")
			.expect("[smtp2tg.toml] missing table \"recipients\".\n")
		{
			let value = value.into_int()
				.context("[smtp2tg.toml] \"recipient\" table values should be integers.\n")?;
			recipients.insert(name.to_lowercase().replace('.', ""), value);
		}

		let tg = Arc::new(TelegramTransport::new(api_key, recipients, &settings)?);
		let fields = HashSet::<String>::from_iter(settings.get_array("fields")
			.expect("[smtp2tg.toml] \"fields\" should be an array")
			.iter().map(|x| x.clone().into_string().expect("should be strings")));

		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 {
				panic!("[smtp2tg.toml] can't check of domains in \"domains\": {domain}");
			}
		}



		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!(),







|








|
|
>







|


>
>
>







72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
	/// Returns an error if required configuration values are missing or invalid.
	/// server fails to start.
	pub fn new (settings: config::Config) -> Result<MailServer> {
		let api_key = settings.get_string("api_key")
			.context("[smtp2tg.toml] missing \"api_key\" parameter.\n")?;
		let mut recipients = HashMap::new();
		for (name, value) in settings.get_table("recipients")
			.context("[smtp2tg.toml] missing table \"recipients\".\n")?
		{
			let value = value.into_int()
				.context("[smtp2tg.toml] \"recipient\" table values should be integers.\n")?;
			recipients.insert(name.to_lowercase().replace('.', ""), value);
		}

		let tg = Arc::new(TelegramTransport::new(api_key, recipients, &settings)?);
		let fields = HashSet::<String>::from_iter(settings.get_array("fields")
			.context("[smtp2tg.toml] \"fields\" should be an array")?
			.iter().map(|x| x.clone().into_string().context("should be strings"))
			.collect::<Result<Vec<String>>>()?);
		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!(),
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52




53




















54









55










56

57
58
use smtp2tg::mail::MailServer;


use stacked_errors::{
	Result,
	StackableErr,


};

use tgbot::types::ChatPeerId;
/// Builds a `MailServer` purely from an in-memory TOML source, no
/// network access is performed while constructing it.
fn build_server () -> Result<MailServer> {
	let settings = config::Config::builder()
		.add_source(config::File::from_str(r#"
			api_key = "test-api-key"
			api_gateway = "https://api.telegram.org"
			default = 0
			fields = ["date", "from", "subject"]
			domains = ["example.com"]

			[recipients]
			"someone@example.com" = 1
			"root" = -1
		"#, config::FileFormat::Toml))
		.build()
		.stack()?;
	MailServer::new(settings)
}

#[test]
fn get_id_properly_resolves_addresses () -> Result<()> {
	let server = build_server()?;
	let cases = [
		("someone@example.com", 1),
		("someone", 0),
		("root", -1),
		("unknown@example.com", 0),
		("SOMEONE@example.com", 1),	// uppercase local part
		("someone@EXAMPLE.COM", 1),	// uppercase domain
		("some.one@example.com", 1),	// functionally equivalent to skipping '.'
		("some-one-2", 0),	// Hyphens
	];
	for (email, id) in cases {
		assert_eq!(*server.get_id(email)?, ChatPeerId::from(id), "email [{email}] expected to return id [{id}]");
	}
	let cases = [
		"someone@otherdomain.net",
		"@example.com",             // empty local part
		"some@one@example.com",     // more than one '@'
		"someone@example.com.evil",
		"someone@example.org",
	];
	for email in cases {




		let err = server.get_id(email).err()




















			.ok_or_else(|| format!("email [{email}] expected to fail")).stack()?;









		assert!(err.to_string().contains("Doesn't look like address from one of our domains."));










	}

	Ok(())
}


>



>
>



|
|
|
|










|

|
<
<
<
<
<
<











|









>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>

>


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
36
37
38
39
40
41
42
43
44
45
46
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
88
89
90
91
92
93
94
95
96
97
98
99
use smtp2tg::mail::MailServer;

use config::FileFormat::Toml;
use stacked_errors::{
	Result,
	StackableErr,
	ensure,
	ensure_eq,
};

use tgbot::types::ChatPeerId;

#[test]
fn get_id_properly_resolves_addresses () -> Result<()> {
	let server = MailServer::new(config::Config::builder()
		.add_source(config::File::from_str(r#"
			api_key = "test-api-key"
			api_gateway = "https://api.telegram.org"
			default = 0
			fields = ["date", "from", "subject"]
			domains = ["example.com"]

			[recipients]
			"someone@example.com" = 1
			"root" = -1
		"#, Toml))
		.build()
		.stack()?)?;






	let cases = [
		("someone@example.com", 1),
		("someone", 0),
		("root", -1),
		("unknown@example.com", 0),
		("SOMEONE@example.com", 1),	// uppercase local part
		("someone@EXAMPLE.COM", 1),	// uppercase domain
		("some.one@example.com", 1),	// functionally equivalent to skipping '.'
		("some-one-2", 0),	// Hyphens
	];
	for (email, id) in cases {
		ensure_eq!(*server.get_id(email)?, ChatPeerId::from(id), format!("email [{email}] expected to return id [{id}]"));
	}
	let cases = [
		"someone@otherdomain.net",
		"@example.com",             // empty local part
		"some@one@example.com",     // more than one '@'
		"someone@example.com.evil",
		"someone@example.org",
	];
	for email in cases {
		ensure!(server.get_id(email).is_err(), format!("this email should be rejected: {email}"));
	}
	Ok(())
}

#[test]
fn wrong_server_config () -> Result<()> {
	let configs = [
		"domains = []",
		"",
		"[recipents]\na = 1",
		r#"
		api_key = "test-api-key"
		api_gateway = "https://api.telegram.org"
		default = 0
		fields = ["date", "from", "subject"]
		domains = ["example.com"]
		# no recipients
		"#,
		r#"
		ap_key = "test-api-key" # bad one
		api_gateway = "https://api.telegram.org"
		default = 0
		fields = ["date", "from", "subject"]
		domains = ["example.com"]

		[recipients]
		"someone@example.com" = 1
		"root" = -1"#,
		r#"
		api_key = "test-api-key"
		api_gateway = "https://api.telegram.org"
		default = 0
		fields = ["date", "from", "subject"]
		domains = [] # empty

		[recipients]
		"someone@example.com" = 1
		"root" = -1"#,
	];
	for config in configs {
		let settings = config::Config::builder()
			.add_source(config::File::from_str(config, Toml))
			.build()
			.stack()?;
		ensure!(MailServer::new(settings).is_err(), format!("this config shouldn't be valid:\n{config}"));
	}
	
	Ok(())
}
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
36
37
38
39
40
41
42
43
44
45
46
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
use smtp2tg::utils::{
	validate,
	RE_CLOSING,
	RE_DOMAIN,
};

use std::{
	borrow::Cow,
	mem::discriminant,
};

use stacked_errors::Result;




#[test]
fn test_validate_escaping_behavior () -> Result<()> {
	let cases: &[(&str, Cow<str>)] = &[
		// `validate` escapes HTML special characters.
		("<p>Some <b>valid</b> HTML</p>", Cow::Owned("&lt;p&gt;Some &lt;b&gt;valid&lt;/b&gt; HTML&lt;/p&gt;".into())),
		// Empty input is returned unchanged.
		("", Cow::Borrowed("")),
		// Whitespace-only input needs no escaping.
		("   \t\n", Cow::Borrowed("   \t\n")),
		// `validate` returns `Cow<'a, str>` borrowed from its input lifetime `'a`.
		// These two cases exercise both branches of that `Cow` to make sure the
		// explicit lifetime introduced on `validate` still lets callers observe a
		// zero-copy borrow when no escaping is required.
		("plain text without special html characters", Cow::Borrowed("plain text without special html characters")),
		("5 > 3 & 2 < 4", Cow::Owned("5 &gt; 3 &amp; 2 &lt; 4".into())),
	];
	for (input, expected) in cases {
		let result = validate(input)?;
		assert_eq!(&result, expected, "unexpected output for input {input:?}");
		assert_eq!(discriminant(&result), discriminant(expected), "wrong Cow variant for input {input:?}");
	}
	Ok(())
}

#[test]
fn test_validate_closing_tag_behavior () {
	let cases = [
		("</  pre  >", true),
		("</\tcode\t>", true),
		("</b>", false),
		("</Code>", true),
		("</code>", true),
		("</code>\t", true),
		("</code>\t>", true),
		("</div>", false), // Not a pre/code tag
		("</PRE>", true),
		("</pre>", true),
		("</pre>\n", true),
		("<p>Some <b>valid</b> HTML</p></code><a href='http://somewere.com'>Link injection!</a>", true),
		("<pre>", false),
	];
	for (input, expected) in cases {
		assert_eq!(RE_CLOSING.is_match(input), expected, "unexpected match result for {input:?}");
	}

}

#[test]
fn test_regex_domain_behavior() {
	let cases = [
		("", false),
		("-example.com", false),
		(".example.com", false),
		("123.456", true),
		("EXAMPLE.COM", false),
		("a", true),
		("a.b", true),
		("example-.com", false),
		("example..com", false),
		("example.com", true),
		("example.com.", false),
		("invalid@domain.com", false),
		("my-host.example.com", true),
		("sub.example.co.uk", true),
	];
	for (input, expected) in cases {
		assert_eq!(RE_DOMAIN.is_match(input), expected, "unexpected match result for {input:?}");
	}

}











|
>
>
>



















|
|





|
















|

>



|

















|

>

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
36
37
38
39
40
41
42
43
44
45
46
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
use smtp2tg::utils::{
	validate,
	RE_CLOSING,
	RE_DOMAIN,
};

use std::{
	borrow::Cow,
	mem::discriminant,
};

use stacked_errors::{
	Result,
	ensure_eq,
};

#[test]
fn test_validate_escaping_behavior () -> Result<()> {
	let cases: &[(&str, Cow<str>)] = &[
		// `validate` escapes HTML special characters.
		("<p>Some <b>valid</b> HTML</p>", Cow::Owned("&lt;p&gt;Some &lt;b&gt;valid&lt;/b&gt; HTML&lt;/p&gt;".into())),
		// Empty input is returned unchanged.
		("", Cow::Borrowed("")),
		// Whitespace-only input needs no escaping.
		("   \t\n", Cow::Borrowed("   \t\n")),
		// `validate` returns `Cow<'a, str>` borrowed from its input lifetime `'a`.
		// These two cases exercise both branches of that `Cow` to make sure the
		// explicit lifetime introduced on `validate` still lets callers observe a
		// zero-copy borrow when no escaping is required.
		("plain text without special html characters", Cow::Borrowed("plain text without special html characters")),
		("5 > 3 & 2 < 4", Cow::Owned("5 &gt; 3 &amp; 2 &lt; 4".into())),
	];
	for (input, expected) in cases {
		let result = validate(input)?;
		ensure_eq!(&result, expected, format!("unexpected output for input {input:?}"));
		ensure_eq!(discriminant(&result), discriminant(expected), format!("wrong Cow variant for input {input:?}"));
	}
	Ok(())
}

#[test]
fn test_validate_closing_tag_behavior () -> Result<()> {
	let cases = [
		("</  pre  >", true),
		("</\tcode\t>", true),
		("</b>", false),
		("</Code>", true),
		("</code>", true),
		("</code>\t", true),
		("</code>\t>", true),
		("</div>", false), // Not a pre/code tag
		("</PRE>", true),
		("</pre>", true),
		("</pre>\n", true),
		("<p>Some <b>valid</b> HTML</p></code><a href='http://somewere.com'>Link injection!</a>", true),
		("<pre>", false),
	];
	for (input, expected) in cases {
		ensure_eq!(RE_CLOSING.is_match(input), expected, format!("unexpected match result for {input:?}"));
	}
	Ok(())
}

#[test]
fn test_regex_domain_behavior() -> Result<()> {
	let cases = [
		("", false),
		("-example.com", false),
		(".example.com", false),
		("123.456", true),
		("EXAMPLE.COM", false),
		("a", true),
		("a.b", true),
		("example-.com", false),
		("example..com", false),
		("example.com", true),
		("example.com.", false),
		("invalid@domain.com", false),
		("my-host.example.com", true),
		("sub.example.co.uk", true),
	];
	for (input, expected) in cases {
		ensure_eq!(RE_DOMAIN.is_match(input), expected, format!("unexpected match result for {input:?}"));
	}
	Ok(())
}