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
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().expect("Failed to get current hostname")
			.to_str().expect("Can't convert hostname to string, bad UTF-8?")]).stack()?
			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
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")
			.expect("[smtp2tg.toml] missing table \"recipients\".\n")
			.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")
			.expect("[smtp2tg.toml] \"fields\" should be an array")
			.iter().map(|x| x.clone().into_string().expect("should be strings")));
			.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 {
				panic!("[smtp2tg.toml] can't check of domains in \"domains\": {domain}");
				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
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;
/// 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()

#[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
		"#, config::FileFormat::Toml))
		"#, Toml))
		.build()
		.stack()?;
		.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}]");
		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(())
}
		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."));

#[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
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;
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)?;
		assert_eq!(&result, expected, "unexpected output for input {input:?}");
		assert_eq!(discriminant(&result), discriminant(expected), "wrong Cow variant for input {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 () {
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 {
		assert_eq!(RE_CLOSING.is_match(input), expected, "unexpected match result for {input:?}");
		ensure_eq!(RE_CLOSING.is_match(input), expected, format!("unexpected match result for {input:?}"));
	}
	Ok(())
}

#[test]
fn test_regex_domain_behavior() {
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 {
		assert_eq!(RE_DOMAIN.is_match(input), expected, "unexpected match result for {input:?}");
		ensure_eq!(RE_DOMAIN.is_match(input), expected, format!("unexpected match result for {input:?}"));
	}
	Ok(())
}