Check-in [78b11b0319]
Logged in as anonymous
Overview
Comment:chores/fixes - more docstrings - drop just-getopt for clap - drop lazy_static - ditch panic/expect/assert in favor of error propagation - fix email parsing and validation - more checks - minify - drop relay switch, wasn't working since moving to mailin - lot more tests
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | release | v0.6.7
Files: files | file ages | folders
SHA3-256: 78b11b0319317aab0feb0f397b9bfc88cb5d5106cb7d46585182f4af02b8529b
User & Date: arcade on 2026-08-01 19:36:39.483
Other Links: branch diff | manifest | tags
Context
2026-08-01
19:36
chores/fixes - more docstrings - drop just-getopt for clap - drop lazy_static - ditch panic/expect/assert in favor of error propagation - fix email parsing and validation - more checks - minify - drop relay switch, wasn't working since moving to mailin - lot more tests leaf check-in: 78b11b0319 user: arcade tags: release, v0.6.7
19:32
and fix tests leaf check-in: 40a93e9a58 user: arcade tags: trunk
2026-07-31
15:53
bumps, fixes, tests - quinn-proto: https://rustsec.org/advisories/RUSTSEC-2026-0185 Remote memory exhaustion in quinn-proto from unbounded out-of-order stream reassembly - serde_with: https://github.com/jonasbb/serde_with/security/advisories/GHSA-7gcf-g7xr-8hxj KeyValueMap serialization panics on empty sequence or map entries Bad or attacker controlled values could cause a panic while allocating too large values. - html tags are now case insensitive - error propagation and handling fixes - more tests, convert to lib+bin check-in: f4660639ca user: arcade tags: release, v0.6.6
Changes
1
2
3
4
5
6
7
8
9
10
11
12


















































13
14
15
16
17
18
19
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












+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+







# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4

[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
 "memchr",
]

[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
 "anstyle",
 "anstyle-parse",
 "anstyle-query",
 "anstyle-wincon",
 "colorchoice",
 "is_terminal_polyfill",
 "utf8parse",
]

[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"

[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
 "utf8parse",
]

[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
 "windows-sys 0.61.2",
]

[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
 "anstyle",
 "once_cell_polyfill",
 "windows-sys 0.61.2",
]

[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
289
290
291
292
293
294
295








































296
297
298
299
300
301
302
303
304






305
306
307
308
309
310
311
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407







+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+









+
+
+
+
+
+







checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
 "cfg-if",
 "cpufeatures",
 "rand_core",
]

[[package]]
name = "clap"
version = "4.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf"
dependencies = [
 "clap_builder",
 "clap_derive",
]

[[package]]
name = "clap_builder"
version = "4.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078"
dependencies = [
 "anstream",
 "anstyle",
 "clap_lex",
 "strsim",
]

[[package]]
name = "clap_derive"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [
 "heck",
 "proc-macro2",
 "quote",
 "syn 3.0.3",
]

[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"

[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
 "cc",
]

[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"

[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
 "bytes",
619
620
621
622
623
624
625






626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645

646
647

648
649
650
651
652
653
654
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746

747
748

749
750
751
752
753
754
755
756







+
+
+
+
+
+



















-
+

-
+







dependencies = [
 "indexmap",
 "proc-macro2",
 "quote",
 "syn 2.0.119",
]

[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"

[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"

[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
 "cfg-if",
 "libc",
 "windows-link",
]

[[package]]
name = "html-escape"
version = "0.2.14"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e"
checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"

[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
864
865
866
867
868
869
870






871
872
873
874
875
876
877
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985







+
+
+
+
+
+








[[package]]
name = "ipnet"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"

[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"

[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"

[[package]]
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
1048
1049
1050
1051
1052
1053
1054






1055
1056
1057
1058
1059
1060
1061







-
-
-
-
-
-







checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
 "cfg-if",
 "futures-util",
 "wasm-bindgen",
]

[[package]]
name = "just-getopt"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97185bbe8f4edbaf6a3acc4f3f723358488d2da28e8f12a83529ddc6268f9c30"

[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"

[[package]]
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1091
1092
1093
1094
1095
1096
1097

1098
1099
1100
1101
1102
1103
1104







-







[[package]]
name = "mail-parser"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47785d444be4d32c1709171c6219a90f667c0ad0ffe68b4b179e794f31f4f9e8"
dependencies = [
 "hashify",
 "serde",
]

[[package]]
name = "mailin"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "261c858a4b0f914d12dd9da38b9e55a7d991cd6c3e369f503344c4f1c2137fc4"
1075
1076
1077
1078
1079
1080
1081






1082
1083
1084
1085
1086
1087
1088
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195







+
+
+
+
+
+








[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"

[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"

[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"

[[package]]
1667
1668
1669
1670
1671
1672
1673
1674

1675
1676

1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1774
1775
1776
1777
1778
1779
1780

1781
1782
1783
1784
1785
1786
1787


1788
1789
1790
1791
1792
1793
1794







-
+


+



-
-







 "async-process",
 "blocking",
 "futures-lite",
]

[[package]]
name = "smtp2tg"
version = "0.6.6"
version = "0.6.7"
dependencies = [
 "async-compat",
 "clap",
 "config",
 "hostname",
 "html-escape",
 "just-getopt",
 "lazy_static",
 "mail-parser",
 "mailin-embedded",
 "regex",
 "smol",
 "stacked_errors",
 "tgbot",
]
2019
2020
2021
2022
2023
2024
2025






2026
2027
2028
2029
2030
2031
2032
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144







+
+
+
+
+
+








[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"

[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"

[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
 "same-file",
1
2
3

4
5
6
7
8
9
10
11
12
13
14

15
16

17
18
19
20
21
22
23
1
2

3
4
5
6
7
8
9
10
11
12
13

14


15
16
17
18
19
20
21
22


-
+










-
+
-
-
+







[package]
name = "smtp2tg"
version = "0.6.6"
version = "0.6.7"
authors = [ "arcade@b1t.name" ]
edition = "2024"
license = "0BSD"
repository = "http://fs.b1t.name/smtp2tg"

[dependencies]
async-compat = "0.2.5"
config = { version = "0.15", default-features = false, features = [ "toml" ] }
hostname = "0.4.1"
html-escape = "0.2.13"
just-getopt = "2.0.0"
clap = { version = "4.6", features = [ "derive" ] }
lazy_static = "1.5.0"
mail-parser = { version = "0.11", features = ["serde"] }
mail-parser = "0.11"
mailin-embedded = "0.8"
regex = "1.11.1"
smol = "2.0.2"
stacked_errors = "0.7.1"
tgbot = "0.46"

[profile.release]
Renamed and modified README [e538a2e7fd] to README.md [3481cdb912].

1
2
3






4






5
6
7














8

9
10























11

12
13
14















15





16







17


18










19


20


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

100
101
+
-
-
-
+
+
+
+
+
+

+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
-
+
+
+
+
+
+
+

+
+
-
+
+
+
+
+
+
+
+
+
+

+
+
-
+
+
# smtp2tg
First of all make sure to never listen on public IPs and always put sockets in
directories not acessible by just about everyone on your system. Though
listening on 127.0.0.1:XXXX is equivalent to creating a world-writable socket.

SMTP-to-Telegram gateway. Receives emails via SMTP and forwards them to Telegram chats.

---

## Installation

### From source
```bash
git clone https://github.com/kworr/smtp2tg
cd smtp2tg
cargo build --release
```
To catch bounces (so they wouldn't stuck in upper mail server) make sure sender
envelope address is real as required by mail library (actually not sure whether
this applies to mailin). For example Postfix has to be tweaked like this:

## Configuration

1. Create `smtp2tg.toml` (see `smtp2tg.toml.example` for reference).
2. Get a Telegram Bot Token from [@BotFather](https://t.me/BotFather).
3. Get your chat ID (use [@getidsbot](https://t.me/getidsbot) or debug mode in Telegram client).

### Example configuration
```toml
api_key = "replace-with-your-telegram-bot-token"
api_gateway = "https://api.telegram.org"
listen_on = "127.0.0.1:1025"
fields = ["date", "from", "subject"]
domains = ["example.com", "localhost"]

default = 0
$config_directory/main.cf:
	smtp_generic_maps = hash:$config_directory/generic

[recipients]
"admin@example.com" = 12345678
"alerts@example.com" = -10012345678
```

All recipients are internally lowercased, so `UserName` equals `username`.

---

## Usage

### Run
```bash
./smtp2tg -c /path/to/smtp2tg.toml
```

### CLI arguments

| Argument      | Description               | Example               |
|---------------|---------------------------|-----------------------|
| `-h`, `--help` | Show help                 | `smtp2tg --help`      |
| `-c`, `--config` | Path to config file     | `smtp2tg -c config.toml` |

---
$config_directory/generic:
	""	postmaster@example.com
	<>	postmaster@example.com

## Security

### Important
- **Recommended usage**: Run on `127.0.0.1` (localhost) only.
- If exposing to a network:
  - **Restrict port access** via firewall.
  - **Use TLS** (e.g., via `stunnel` or `nginx`).
  - **Implement authentication** (this software does not provide it).

### Config file permissions
Set file permissions to `0600` (owner read/write only):
```bash
chmod 600 smtp2tg.toml
```

---
## How it works
1. A client (e.g., Postfix) sends an email to `listen_on` (e.g., `127.0.0.1:1025`).
2. smtp2tg parses the email and converts it to a Telegram message.
3. The message is sent to the specified chat (or `default` if address is unknown).
Actually not sure which one works...

### Example: Email → Telegram
**Incoming email:**
```text
From: user@example.com
To: admin@example.com
Subject: Test

Hello, world!
```
PS: If you are reading this on GitHub - it's a RO mirror from original repo:

**Telegram message:**
```html
<blockquote expandable>
<u><i>Subject:</i></u> <code>Test</code>
<u><i>From:</i></u> <code>user@example.com</code>
<u><i>Date:</i></u> <code>Mon, 01 Jan 2024 12:00:00 +0000</code>
</blockquote>
<pre>Hello, world!</pre>
```

---
## Links
http://fs.b1t.name/smtp2tg/timeline
- **Original repository**: [http://fs.b1t.name/smtp2tg](http://fs.b1t.name/smtp2tg)
- **GitHub Mirror**: [https://github.com/kworr/smtp2tg](https://github.com/kworr/smtp2tg)









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
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
+
+
+
+
+
+
+
+
+




-
-
-
-








-
+
-
-
-
-









-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
-
-
+
-
-
-
-















-

-
-
+
+










+







//! SMTP-to-Telegram gateway main library.
//!
//! This module provides the core functionality for receiving emails via SMTP
//! and forwarding them to Telegram chats.
//!
//! As we are not actually exporting this lib there would be no local Error's
//! for now, everything will be just .stack()?'ed and propagated like in real
//! bin The lib here is just to separate all tests from main code into tests/

pub mod mail;
mod telegram;
pub mod utils;

// As we are not actually exporting this lib there would be no local Error's for
// now, everything will be just .stack()?'ed and propagated like in real bin
// The lib here is just to separate all tests from main code into tests/

use crate::mail::MailServer;

use std::{
	io::Cursor,
	os::unix::fs::PermissionsExt,
	path::Path,
};

use just_getopt::{
use clap::Parser;
	OptFlags,
	OptSpecs,
	OptValue,
};
use smol::{
	fs::metadata,
};
use stacked_errors::{
	Result,
	StackableErr,
	bail,
};

/// Actual main function running async with Error propagation support
pub async fn async_main () -> Result<()> {
	let specs = OptSpecs::new()
		.option("help", "h", OptValue::None)
		.option("help", "help", OptValue::None)
		.option("config", "c", OptValue::Required)
/// SMTP-to-Telegram gateway
#[derive(Parser, Debug)]
#[command(name = "smtp2tg")]
#[command(about = format!("SMTP-to-Telegram gateway v{}, (C) 2024 - 2026", env!("CARGO_PKG_VERSION")), long_about = None)]
struct Args {
	/// Set configuration file location
		.option("config", "config", OptValue::Required)
		.flag(OptFlags::OptionsEverywhere);
	let mut args = std::env::args();
	args.next();
	#[arg(short, long, default_value = "smtp2tg.toml")]
	let parsed = specs.getopt(args);
	for u in &parsed.unknown {
		println!("Unknown option: {u}");
	}
	if !(parsed.unknown.is_empty()) || parsed.options_first("help").is_some() {
		println!("SMTP2TG v{}, (C) 2024 - 2026\n\n\
	config: String,
}

/// Main asynchronous entry point for the application.
///
/// Parses command-line arguments, loads configuration, and starts the SMTP
/// server.
///
/// # Errors
			\t-h|--help\tDisplay this help\n\
			\t-c|--config …\tSet configuration file location.",
			env!("CARGO_PKG_VERSION"));
		return Ok(());
/// 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(if let Some(path) = parsed.options_value_last("config") {
	let config_file = Path::new(&args.config);
		&path[..]
	} else {
		"smtp2tg.toml"
	});
	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("unknown", "relay").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(())
}





1
2
3
4
5
6
7
1
2
3
4
5
6
7
8
9
10
11
12
+
+
+
+
+







//! SMTP server implementation for receiving and processing emails.
//!
//! This module handles SMTP connections, email parsing, and forwarding to
//! Telegram.

use crate::{
	Cursor,
	telegram::TelegramTransport,
	utils::{
		Attachment,
		RE_DOMAIN,
		validate,
25
26
27
28
29
30
31

32
33
34
35
36
37
38
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44







+







		INVALID_CREDENTIALS,
		NO_MAILBOX,
		OK
	},
};
use regex::{
	Regex,
	RegexBuilder,
	escape,
};
use stacked_errors::{
	Result,
	StackableErr,
	bail,
};
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
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
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
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
143

144
145
146
147
148
149
150
151







-






+
-
-
+
+
+
+
+
+
+
+
+




-
+



-
+




-
-
+
+
+







-
+


+
+
+


-
+
-
-
-
-
+
-
-
-
-
-




-






+
+
+
+
+
+
-
-
+
+
+
+
-
-
-
-
+
-
-
-
+
+
-
-
+
-












-
+







}

/// `MailServer` Central object with TG api and configuration
#[derive(Clone, Debug)]
pub struct MailServer {
	data: Vec<u8>,
	headers: Option<SomeHeaders>,
	relay: bool,
	tg: Arc<TelegramTransport>,
	fields: HashSet<String>,
	address: Regex,
}

impl MailServer {
	/// Initializes the mail server: sets up the Telegram API client and
	/// Initialize API and read configuration
	pub fn new(settings: config::Config) -> Result<MailServer> {
	/// validates all required configuration values.
	///
	/// # Arguments
	/// * `settings` - Parsed application configuration.
	///
	/// # Errors
	/// 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, value);
			recipients.insert(name.to_lowercase(), 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 = Regex::new(&format!("^(?P<user>[a-z0-9][-a-z0-9])(@({domains}))$")).stack()?;
		let address = RegexBuilder::new(&format!("^[a-z0-9][a-z0-9.-]*(@({domains}))?$"))
		let relay = match settings.get_string("unknown")
			.context("[smtp2tg.toml] can't get \"unknown\" policy.\n")?.as_str()
		{
			"relay" => true,
			.case_insensitive(true).build().stack()?;
			"deny" => false,
			_ => {
				bail!("[smtp2tg.toml] \"unknown\" should be either \"relay\" or \"deny\".\n");
			},
		};

		Ok(MailServer {
			data: vec!(),
			headers: None,
			relay,
			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 id for provided email address
	pub fn get_id (&self, name_str: &str) -> Result<&ChatPeerId> {
	/// # Returns
	/// * `Result<ChatPeerId>` - Telegram chat ID for the address, or default if
	///   not found.
	pub fn get_id (&self, name: &str) -> Result<&ChatPeerId> {
		// here we need to store String locally to borrow it after
		let mut link = name_str;
		let name: String;
		if let Some(caps) = self.address.captures(link) {
		if self.address.is_match(name) {
			name = caps["name"].to_string();
			link = &name;
		}
			Ok(self.tg.get(name).unwrap_or(&self.tg.default))
		} else {
		match self.tg.get(link) {
			Ok(addr) => Ok(addr),
			bail!("Doesn't look like address from one of our domains.");
			Err(_) => Ok(&self.tg.default),
		}
	}

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

			// Adding all known addresses to recipient list, for anyone else adding default
			// Also if list is empty also adding default
			let mut rcpt: HashSet<&ChatPeerId> = HashSet::new();
			if headers.to.is_empty() && !self.relay {
			if headers.to.is_empty() {
				bail!("Relaying is disabled, and there's no destination address");
			}
			for item in &headers.to {
				rcpt.insert(self.get_id(item)?);
			};
			if rcpt.is_empty() {
				self.tg.debug("No recipient or envelope address.").await?;
259
260
261
262
263
264
265

266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282

283
284
285
286
287
288



289
290
291
292
293
294
295
296
297
298
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289




290






291
292
293



294
295
296
297
298
299
300







+













-
-
-
-
+
-
-
-
-
-
-
+
+
+
-
-
-







		} else {
			bail!("Required headers were not found.");
		}
		Ok(())
	}
}

/// SMTP handler implementation for mailin-embedded.
impl mailin_embedded::Handler for MailServer {
	/// Just deny login auth
	fn auth_login (&mut self, _username: &str, _password: &str) -> Response {
		INVALID_CREDENTIALS
	}

	/// Just deny plain auth
	fn auth_plain (&mut self, _authorization_id: &str, _authentication_id: &str, _password: &str) -> Response {
		INVALID_CREDENTIALS
	}

	/// Verify whether address is deliverable
	fn rcpt (&mut self, to: &str) -> Response {
		if self.relay {
			OK
		} else {
			match self.get_id(to) {
		if self.get_id(to).is_ok() {
				Ok(_) => OK,
				Err(_) => {
					if self.relay {
						OK
					} else {
						NO_MAILBOX
			OK
		} else {
			NO_MAILBOX
					}
				}
			}
		}
	}

	/// Save headers we need
	fn data_start (&mut self, _domain: &str, from: &str, _is8bit: bool, to: &[String]) -> Response {
		self.headers = Some(SomeHeaders{
			from: from.to_string(),


1
2
3
4
5
6
7
8
9
10
11

12
13
14
15
16
17
18
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
+
+











+







//! Telegram API integration for sending messages and attachments.

use crate::utils::{
	Attachment,
	validate,
};

use std::{
	collections::HashMap,
	fmt::Debug,
};

use stacked_errors::{
	bail,
	Result,
	StackableErr,
};
use tgbot::{
	api::Client,
	types::{
		ChatPeerId,
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
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
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







-
+
+
+
+
+
+
+
+
+
+




















-
+
+
+
+
+
+
+
+
+
+




+
-
+
+
+
+
+
+
+
+
+

-
+



-
+
+
+
+
+
+
+
+








-
+
+
+
+
+
+
+
+
+







pub struct TelegramTransport {
	tg: Client,
	recipients: HashMap<String, ChatPeerId>,
	pub default: ChatPeerId,
}

impl TelegramTransport {
	/// Creates new TelegramTransport object.
	/// Creates a new `TelegramTransport` instance.
	///
	/// # Arguments
	/// * `api_key` - Telegram Bot API token.
	/// * `recipients` - Mapping of email addresses to Telegram chat IDs.
	/// * `settings` - Additional configuration (API gateway, default chat).
	///
	/// # Errors
	/// Returns an error if configuration values cannot be read or if Telegram
	/// API client creation fails.
	pub fn new (api_key: String, recipients: HashMap<String, i64>, settings: &config::Config) -> Result<TelegramTransport> {
		let default = settings.get_int("default")
			.context("[smtp2tg.toml] missing \"default\" recipient.\n")?;
		let api_gateway = settings.get_string("api_gateway")
			.context("[smtp2tg.toml] missing \"api_gateway\" destination.\n")?;

		let tg = Client::new(api_key)
			.context("Failed to create API.\n")?
			.with_host(api_gateway);
		let recipients = recipients.into_iter()
			.map(|(a, b)| (a, ChatPeerId::from(b))).collect();
		let default = ChatPeerId::from(default);

		Ok(TelegramTransport {
			tg,
			recipients,
			default,
		})
	}

	/// Send message to default user, used for debug/log/info purposes
	/// Sends a debug message to the default chat.
	///
	/// # Arguments
	/// * `msg` - Message text to send.
	///
	/// # Returns
	/// * `Result<Message>` - Telegram API response.
	///
	/// # Errors
	/// Returns an error if `msg` contains a closing Telegram tag or sending fails.
	pub async fn debug (&self, msg: &str) -> Result<Message> {
		self.send(&self.default, format!("<pre>{}</pre>", validate(msg).stack()?)).await
	}

	/// Retrieves a chat ID by name.
	/// Get recipient by address
	///
	/// # Arguments
	/// * `name` - Name or email to look up.
	///
	/// # 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)
		self.recipients.get(&name.to_lowercase())
			.with_context(|| format!("Recipient \"{name}\" not found in configuration"))
	}

	/// Send message to specified user
	/// Sends a text message to a specified chat.
	///
	/// # Arguments
	/// * `to` - Target chat ID.
	/// * `msg` - Message text (supports HTML formatting).
	///
	/// # Returns
	/// * `Result<Message>` - Telegram API response.
	pub async fn send <S> (&self, to: &ChatPeerId, msg: S) -> Result<Message>
	where S: Into<String> + Debug{
		self.tg.execute(
			SendMessage::new(*to, msg)
			.with_parse_mode(Html)
		).await.stack()
	}

	/// Send media to specified user
	/// Sends a message with attachments to a specified chat.
	///
	/// # Arguments
	/// * `to` - Target chat ID.
	/// * `media` - List of attachments, non-empty.
	/// * `msg` - Message text (supports HTML formatting).
	///
	/// # Returns
	/// * `Result<()>` - Success or error.
	pub async fn sendgroup (&self, to: &ChatPeerId, media: Vec<Attachment>, msg: &str) -> Result<()> {
		if media.len() > 1 {
			let mut attach = vec![];
			let mut pos = media.len();
			for file in media {
				let mut caption = InputMediaDocument::default();
				if pos == 1 {
98
99
100
101
102
103
104



105
106
107
108
109
110
111
112
113
114
115
116
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164







+
+
+












						),
						caption
					)
				);
			}
			self.tg.execute(SendMediaGroup::new(*to, MediaGroup::new(attach).stack()?)).await.stack()?;
		} else {
			if media.is_empty() {
				bail!("At least one attachment is required.");
			}
			self.tg.execute(
				SendDocument::new(
					*to,
					InputFileReader::from(media[0].data.clone())
					.with_file_name(media[0].name.clone())
				).with_caption(msg)
				.with_caption_parse_mode(Html)
			).await.stack()?;
		}
		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
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
+
+


-
+
+
+
+


-









-
-
-
-
-
+
+
+
+
+
+
+










-
-
+
+
+
+
+
+
+
+
+
+
+
+







//! Utility functions and types for the application.

use crate::Cursor;

use std::borrow::Cow;
use std::{
	borrow::Cow,
	sync::LazyLock,
};

use html_escape::encode_text;
use lazy_static::lazy_static;
use regex::{
	Regex,
	RegexBuilder,
};
use stacked_errors::{
	bail,
	Result,
};

lazy_static! {
	pub static ref RE_DOMAIN: Regex = Regex::new(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$").expect("Invalid domain regex");
	pub static ref RE_CLOSING: Regex = RegexBuilder::new(r"</[ \t]*(pre|code)[ \t]*>")
		.case_insensitive(true).build().expect("Invalid closing tag regex");
}
pub static RE_DOMAIN: LazyLock<Regex> = LazyLock::new(|| {
	Regex::new(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$").expect("Invalid domain regex")
});
pub static RE_CLOSING: LazyLock<Regex> = LazyLock::new(|| {
	RegexBuilder::new(r"</[ \t]*(pre|code)[ \t]*>")
		.case_insensitive(true).build().expect("Invalid closing tag regex")
});

/// Stores binary attachment data and metadata for Telegram messages.
/// The data is wrapped in a `Cursor<Vec<u8>>` for efficient streaming,
/// while `name` holds the filename or display name of the attachment.
#[derive(Debug)]
pub struct Attachment {
	pub data: Cursor<Vec<u8>>,
	pub name: String,
}

/// Pass any text here to be validated as not breaking from Telegram preformatted blocks
/// escape all HTML chars afterwards
/// Validates text to ensure it doesn't break Telegram's preformatted blocks.
///
/// Escapes HTML special characters to prevent injection.
///
/// # Arguments
/// * `text` - Text to validate and escape.
///
/// # 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))
	}
}
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
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
			unknown = "relay"
			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_returns_configured_recipient () -> 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", 0),	// dot are not skipped
		("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(())
}

#[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(())
}