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: |
78b11b0319317aab0feb0f397b9bfc88 |
| 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
Modified Cargo.lock
from [b2a9dadcba]
to [9b799ddbbf].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | # 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 = "async-channel" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 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 | checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures", "rand_core", ] [[package]] name = "cmake" version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] [[package]] name = "combine" version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 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 | dependencies = [ "indexmap", "proc-macro2", "quote", "syn 2.0.119", ] [[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" | > > > > > > | | | 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.15" source = "registry+https://github.com/rust-lang/crates.io-index" 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 | [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] | > > > > > > | 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 | checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", "wasm-bindgen", ] | < < < < < < | 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 = "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 | [[package]] name = "mail-parser" version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47785d444be4d32c1709171c6219a90f667c0ad0ffe68b4b179e794f31f4f9e8" dependencies = [ "hashify", | < | 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", ] [[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 | [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] | > > > > > > | 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 | "async-process", "blocking", "futures-lite", ] [[package]] name = "smtp2tg" | | > < < | 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.7" dependencies = [ "async-compat", "clap", "config", "hostname", "html-escape", "mail-parser", "mailin-embedded", "regex", "smol", "stacked_errors", "tgbot", ] |
| ︙ | ︙ | |||
2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 | [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", | > > > > > > | 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", |
| ︙ | ︙ |
Modified Cargo.toml
from [0ec4243b5c]
to [2bb7ecc06d].
1 2 | [package] name = "smtp2tg" | | | < | | 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.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"
clap = { version = "4.6", features = [ "derive" ] }
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 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 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 ``` ## 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 [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` | --- ## 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). ### Example: Email → Telegram **Incoming email:** ```text From: user@example.com To: admin@example.com Subject: Test Hello, world! ``` **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 - **Original repository**: [http://fs.b1t.name/smtp2tg](http://fs.b1t.name/smtp2tg) - **GitHub Mirror**: [https://github.com/kworr/smtp2tg](https://github.com/kworr/smtp2tg) |
Modified src/lib.rs
from [2d1a8f5193]
to [984fc307f8].
1 2 3 4 | pub mod mail; mod telegram; pub mod utils; | > > > > > > > > > < < < < | < < < < | | | | | | < < < | < < | | | > > > > > | < | > | | < | < < < < < | | > | 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;
use crate::mail::MailServer;
use std::{
io::Cursor,
os::unix::fs::PermissionsExt,
path::Path,
};
use clap::Parser;
use smol::{
fs::metadata,
};
use stacked_errors::{
Result,
StackableErr,
bail,
};
/// 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
#[arg(short, long, default_value = "smtp2tg.toml")]
config: String,
}
/// Main asynchronous entry point for the application.
///
/// Parses command-line arguments, loads configuration, and starts the SMTP
/// server.
///
/// # 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
server.with_name(server_name)
.with_ssl(mailin_embedded::SslConfig::None).unwrap()
.with_addr(listen_on).unwrap();
server.serve().unwrap();
Ok(())
}
|
Modified src/mail.rs
from [b0961d9144]
to [478a14c8ba].
1 2 3 4 5 6 7 |
use crate::{
Cursor,
telegram::TelegramTransport,
utils::{
Attachment,
RE_DOMAIN,
validate,
| > > > > > | 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 |
INVALID_CREDENTIALS,
NO_MAILBOX,
OK
},
};
use regex::{
Regex,
escape,
};
use stacked_errors::{
Result,
StackableErr,
bail,
};
| > | 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 |
}
/// `MailServer` Central object with TG api and configuration
#[derive(Clone, Debug)]
pub struct MailServer {
data: Vec<u8>,
headers: Option<SomeHeaders>,
| < > | > > > > > > > | | | | | > | > > > | < < < | < < < < < < > > > > > > | > > | < < < | < | | < | < | | 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>,
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
/// * `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")
.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(), 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!(),
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.")?;
// 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() {
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 |
} else {
bail!("Required headers were not found.");
}
Ok(())
}
}
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 {
| > < < < | < < < | | | < < < | 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.get_id(to).is_ok() {
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(),
|
| ︙ | ︙ |
Modified src/telegram.rs
from [9de705aadb]
to [b857ac5a83].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
use crate::utils::{
Attachment,
validate,
};
use std::{
collections::HashMap,
fmt::Debug,
};
use stacked_errors::{
Result,
StackableErr,
};
use tgbot::{
api::Client,
types::{
ChatPeerId,
| > > > | 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 |
pub struct TelegramTransport {
tg: Client,
recipients: HashMap<String, ChatPeerId>,
pub default: ChatPeerId,
}
impl TelegramTransport {
| | > > > > > > > > > | > > > > > > > > > > | > > > > > > > > | | > > > > > > > | > > > > > > > > | 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 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,
})
}
/// 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.
///
/// # 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.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).
///
/// # 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()
}
/// 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 |
),
caption
)
);
}
self.tg.execute(SendMediaGroup::new(*to, MediaGroup::new(attach).stack()?)).await.stack()?;
} else {
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(())
}
}
| > > > | 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(())
}
}
|
Modified src/utils.rs
from [712e343f03]
to [b825a3dd51].
1 2 | use crate::Cursor; | > > | > > > < | | > > | | | | > > > | > > > > > > > | 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,
sync::LazyLock,
};
use html_escape::encode_text;
use regex::{
Regex,
RegexBuilder,
};
use stacked_errors::{
bail,
Result,
};
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,
}
/// 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))
}
}
|
Modified tests/mail.rs
from [5610b717c8]
to [9c60f2ee66].
1 2 3 4 5 6 7 8 |
use smtp2tg::mail::MailServer;
use stacked_errors::{
Result,
StackableErr,
};
use tgbot::types::ChatPeerId;
| > > > | | | | < | | < < < < < < > > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 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", 0), // dot are not skipped
("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(())
}
|
Modified tests/utils.rs
from [afc49aa801]
to [4c9edd9d43].
1 2 3 4 5 6 7 8 9 10 11 |
use smtp2tg::utils::{
validate,
RE_CLOSING,
RE_DOMAIN,
};
use std::{
borrow::Cow,
mem::discriminant,
};
| | > > > | | | | > | | > | 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("<p>Some <b>valid</b> HTML</p>".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 > 3 & 2 < 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(())
}
|