From 4185ebe3c54ace1cf42ab1c953d9a55afb7e16c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Wed, 14 Jan 2026 18:03:07 +0100 Subject: [PATCH 001/166] chore: Init repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 filtermail/.gitignore diff --git a/filtermail/.gitignore b/filtermail/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/filtermail/.gitignore @@ -0,0 +1 @@ +/target From 6340f479ae80f8c03b8375e7fea521b712a9345f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Wed, 14 Jan 2026 18:03:07 +0100 Subject: [PATCH 002/166] feat: Initial implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/Cargo.lock | 1315 +++++++++++++++++++ filtermail/Cargo.toml | 23 + filtermail/src/config.rs | 73 + filtermail/src/error.rs | 13 + filtermail/src/inbound.rs | 113 ++ filtermail/src/main.rs | 75 ++ filtermail/src/message.rs | 204 +++ filtermail/src/openpgp.rs | 269 ++++ filtermail/src/outbound.rs | 145 ++ filtermail/src/rate_limiter.rs | 35 + filtermail/src/smtp_server.rs | 196 +++ filtermail/src/utils.rs | 74 ++ filtermail/test_data/asm.eml | 56 + filtermail/test_data/encrypted.eml | 66 + filtermail/test_data/fake-encrypted.eml | 25 + filtermail/test_data/literal.eml | 44 + filtermail/test_data/mailer-daemon.eml | 46 + filtermail/test_data/mdn.eml | 33 + filtermail/test_data/plain.eml | 23 + filtermail/test_data/securejoin-vc-fake.eml | 21 + filtermail/test_data/securejoin-vc.eml | 21 + 21 files changed, 2870 insertions(+) create mode 100644 filtermail/Cargo.lock create mode 100644 filtermail/Cargo.toml create mode 100644 filtermail/src/config.rs create mode 100644 filtermail/src/error.rs create mode 100644 filtermail/src/inbound.rs create mode 100644 filtermail/src/main.rs create mode 100644 filtermail/src/message.rs create mode 100644 filtermail/src/openpgp.rs create mode 100644 filtermail/src/outbound.rs create mode 100644 filtermail/src/rate_limiter.rs create mode 100644 filtermail/src/smtp_server.rs create mode 100644 filtermail/src/utils.rs create mode 100644 filtermail/test_data/asm.eml create mode 100644 filtermail/test_data/encrypted.eml create mode 100644 filtermail/test_data/fake-encrypted.eml create mode 100644 filtermail/test_data/literal.eml create mode 100644 filtermail/test_data/mailer-daemon.eml create mode 100644 filtermail/test_data/mdn.eml create mode 100644 filtermail/test_data/plain.eml create mode 100644 filtermail/test_data/securejoin-vc-fake.eml create mode 100644 filtermail/test_data/securejoin-vc.eml diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock new file mode 100644 index 00000000..5e72b821 --- /dev/null +++ b/filtermail/Cargo.lock @@ -0,0 +1,1315 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +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 = "ar_archive_writer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a" +dependencies = [ + "object", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "charset" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" +dependencies = [ + "base64", + "encoding_rs", +] + +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "filtermail" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64", + "env_logger", + "lettre", + "log", + "mailparse", + "rstest", + "serde", + "serini", + "testresult", + "thiserror", + "tokio", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jiff" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "async-trait", + "base64", + "chumsky", + "email_address", + "futures-io", + "futures-util", + "idna", + "nom", + "percent-encoding", + "socket2", + "tokio", + "url", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mailparse" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f" +dependencies = [ + "charset", + "data-encoding", + "quoted_printable", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serini" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3948771e3c915493235146627cddf2d65de15396033da39d918637a02fda6fb" +dependencies = [ + "serde", + "thiserror", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "testresult" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614b328ff036a4ef882c61570f72918f7e9c5bee1da33f8e7f91e01daee7e56c" + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml new file mode 100644 index 00000000..4ac61b7e --- /dev/null +++ b/filtermail/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "filtermail" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = "0.1.89" +base64 = "0.22.1" +serde = { version = "1.0.228", features = ["derive"] } +serini = "0.2.2" +tokio = { version = "1.49.0", features = ["full"] } +thiserror = "2.0.17" +mailparse = "0.16.1" +lettre = { version = "0.11.19", default-features = false, features = [ + "smtp-transport", + "tokio1", +] } +log = "0.4.29" +env_logger = "0.11.8" + +[dev-dependencies] +rstest = "0.26.1" +testresult = "0.4.1" diff --git a/filtermail/src/config.rs b/filtermail/src/config.rs new file mode 100644 index 00000000..8465044a --- /dev/null +++ b/filtermail/src/config.rs @@ -0,0 +1,73 @@ +//! Configuration file handling for filtermail. + +use serde::{Deserialize, Deserializer}; +use std::path::{Path, PathBuf}; + +/// Chatmail configuration subset used by filtermail. +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + pub filtermail_smtp_port: u16, + pub filtermail_smtp_port_incoming: u16, + pub postfix_reinject_port: u16, + pub postfix_reinject_port_incoming: u16, + pub max_message_size: usize, + pub max_user_send_per_minute: usize, + #[serde(default, deserialize_with = "deserialize_sequence")] + pub passthrough_senders: Vec, + #[serde(default, deserialize_with = "deserialize_sequence")] + pub passthrough_recipients: Vec, + mail_domain: String, + mailboxes_dir: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct ConfigWrapper { + // The whole actual config is under `params` section. + pub params: Config, +} + +/// Custom deserializer to parse space-separated strings into [`Vec`]. +fn deserialize_sequence<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s: Option = Deserialize::deserialize(deserializer)?; + Ok(match s { + Some(v) => v + .split(' ') + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect(), + None => Vec::new(), + }) +} + +impl Config { + /// Load configuration from a file. + pub fn from_file(path: impl AsRef) -> Result { + let content = std::fs::read_to_string(path)?; + let wrapped_config: ConfigWrapper = serini::from_str(&content)?; + Ok(wrapped_config.params) + } + + /// Get the mailboxes directory, using defaulting to `/home/vmail/mail/` if not set. + fn mailboxes_dir(&self) -> PathBuf { + match &self.mailboxes_dir { + Some(dir) => dir.clone(), + None => PathBuf::from(format!("/home/vmail/mail/{}", self.mail_domain)), + } + } + + /// Check if not encrypted mail is allowed for the given address. + pub fn is_cleartext_ok(&self, addr: &str) -> bool { + if addr.is_empty() || !addr.contains('@') || addr.contains('/') { + return false; + } + + let mut enforce_e2ee = self.mailboxes_dir(); + enforce_e2ee.push(addr); + enforce_e2ee.push("enforceE2EEincoming"); + + !enforce_e2ee.exists() + } +} diff --git a/filtermail/src/error.rs b/filtermail/src/error.rs new file mode 100644 index 00000000..4f6c9c80 --- /dev/null +++ b/filtermail/src/error.rs @@ -0,0 +1,13 @@ +//! Error types. + +/// Error type for filtermail. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("Chatmail config is invalid: {0}")] + Config(#[from] serini::Error), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("OpenPGP packet header is truncated - can't validate!")] + TruncatedHeader, +} diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs new file mode 100644 index 00000000..4eabbf8f --- /dev/null +++ b/filtermail/src/inbound.rs @@ -0,0 +1,113 @@ +//! Module for handling incoming SMTP messages. + +use crate::ENCRYPTION_NEEDED_523; +use crate::config::Config; +use crate::message::{check_encrypted, is_securejoin}; +use crate::smtp_server::SmtpHandler; +use async_trait::async_trait; +use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; +use mailparse::{MailHeaderMap, parse_mail}; +use std::sync::Arc; + +pub use crate::smtp_server::Envelope; +use crate::utils::{extract_address, format_smtp_error}; + +/// Handler for incoming SMTP messages. +pub struct IncomingBeforeQueueHandler { + config: Arc, +} + +impl IncomingBeforeQueueHandler { + pub fn new(config: Config) -> Self { + Self { + config: Arc::new(config), + } + } +} + +#[async_trait] +impl SmtpHandler for IncomingBeforeQueueHandler { + fn handle_mail(&self, _address: &str) -> Result<(), String> { + Ok(()) + } + + fn check_data(&self, envelope: &Envelope) -> Result<(), String> { + log::info!("Processing DATA message from {}", envelope.mail_from); + + let message = match parse_mail(&envelope.data) { + Ok(m) => m, + Err(e) => return Err(format!("500 Failed to parse message: {}", e)), + }; + + let mail_encrypted = check_encrypted(&message, false); + log::debug!("mail_encrypted: {}", mail_encrypted); + log::debug!("is_securejoin: {}", is_securejoin(&message)); + + // Allow encrypted or securejoin messages + if mail_encrypted || is_securejoin(&message) { + log::info!("Incoming: Filtering encrypted mail."); + return Ok(()); + } + + log::info!("Incoming: Filtering unencrypted mail."); + + // Allow cleartext mailer-daemon messages + if let Some(auto_submitted) = message.headers.get_first_value("Auto-Submitted") + && !auto_submitted.is_empty() + { + let from_header = message + .headers + .get_first_value("From") + .unwrap_or_default() + .trim() + .to_string(); + + if let Some(from_addr) = extract_address(&from_header) + && from_addr.to_lowercase().starts_with("mailer-daemon@") + && message.ctype.mimetype == "multipart/report" + { + return Ok(()); + } + } + + for recipient in &envelope.rcpt_to { + if !self.config.is_cleartext_ok(recipient) { + log::info!("Rejected unencrypted mail."); + return Err(ENCRYPTION_NEEDED_523.to_string()); + } + } + + Ok(()) + } + + async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { + log::info!("Re-injecting the mail that passed checks"); + + let mailer = AsyncSmtpTransport::::builder_dangerous("localhost") + .port(self.config.postfix_reinject_port_incoming) + .build(); + + let envelope_data = lettre::address::Envelope::new( + Some( + envelope + .mail_from + .parse() + .map_err(|e| format!("Invalid from address: {}", e))?, + ), + envelope + .rcpt_to + .iter() + .map(|addr| addr.parse()) + .collect::, _>>() + .map_err(|e| format!("Invalid to address: {}", e))?, + ) + .map_err(|e| format!("Failed to create envelope: {}", e))?; + + mailer + .send_raw(&envelope_data, &envelope.data) + .await + .map_err(format_smtp_error)?; + + Ok(()) + } +} diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs new file mode 100644 index 00000000..129af418 --- /dev/null +++ b/filtermail/src/main.rs @@ -0,0 +1,75 @@ +mod config; +pub(crate) mod error; +pub(crate) mod inbound; +pub(crate) mod message; +pub(crate) mod openpgp; +pub(crate) mod outbound; +pub(crate) mod rate_limiter; +pub(crate) mod smtp_server; +pub(crate) mod utils; + +use config::Config; +use env_logger::Env; +use inbound::IncomingBeforeQueueHandler; +use outbound::OutgoingBeforeQueueHandler; +use smtp_server::run_smtp_server; +use std::env; +use std::process; +use std::sync::Arc; + +const ENCRYPTION_NEEDED_523: &str = "523 Encryption Needed: Invalid Unencrypted Mail"; + +#[tokio::main] +async fn main() { + // default to info level + let env = Env::new().filter_or("RUST_LOG", "info"); + env_logger::Builder::from_env(env) + // disable timestamps - automatically added by systemd + .format_timestamp(None) + .init(); + + let args: Vec = env::args().collect(); + if args.len() != 3 { + eprintln!("Usage: {} ", args[0]); + eprintln!(" mode: incoming or outgoing"); + process::exit(1); + } + + let config_path = &args[1]; + let mode = &args[2]; + + if mode != "incoming" && mode != "outgoing" { + eprintln!("Error: mode must be 'incoming' or 'outgoing'"); + process::exit(1); + } + + let config = match Config::from_file(config_path) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to read config: {}", e); + process::exit(1); + } + }; + + if mode == "outgoing" { + let handler = Arc::new(OutgoingBeforeQueueHandler::new(config.clone())); + let addr = format!("127.0.0.1:{}", config.filtermail_smtp_port); + let max_size = config.max_message_size; + log::debug!("Outgoing SMTP server listening on {}", addr); + + if let Err(e) = run_smtp_server(&addr, handler, max_size).await { + eprintln!("Server error: {}", e); + process::exit(1); + } + } else { + let handler = Arc::new(IncomingBeforeQueueHandler::new(config.clone())); + let addr = format!("127.0.0.1:{}", config.filtermail_smtp_port_incoming); + let max_size = config.max_message_size; + log::debug!("Incoming SMTP server listening on {}", addr); + + if let Err(e) = run_smtp_server(&addr, handler, max_size).await { + eprintln!("Server error: {}", e); + process::exit(1); + } + } +} diff --git a/filtermail/src/message.rs b/filtermail/src/message.rs new file mode 100644 index 00000000..234d037c --- /dev/null +++ b/filtermail/src/message.rs @@ -0,0 +1,204 @@ +//! Message-related checks. + +use crate::openpgp::check_armored_payload; +use mailparse::MailHeaderMap; + +/// Check if message is a secure-join message (vc-request or vg-request) +pub fn is_securejoin(mail: &mailparse::ParsedMail) -> bool { + // Check for secure-join header + let secure_join = mail.headers.get_first_value("Secure-Join"); + if let Some(ref val) = secure_join { + if val != "vc-request" && val != "vg-request" { + return false; + } + } else { + return false; + } + + // Must be multipart + if mail.subparts.is_empty() { + return false; + } + + // Must have only one part + if mail.subparts.len() != 1 { + return false; + } + + let part = &mail.subparts[0]; + + // Part must not be multipart + if !part.subparts.is_empty() { + return false; + } + + // Part must be text/plain + if part.ctype.mimetype != "text/plain" { + return false; + } + + // Check payload content + let payload = match part.get_body() { + Ok(p) => p.trim().to_lowercase(), + Err(_) => return false, + }; + + payload == "secure-join: vc-request" || payload == "secure-join: vg-request" +} + +/// Check that the message is an OpenPGP-encrypted message +/// +/// MIME structure must correspond to RFC3156 +pub fn check_encrypted(mail: &mailparse::ParsedMail, outgoing: bool) -> bool { + if mail.subparts.is_empty() { + log::debug!("check_encrypted: not multipart"); + return false; + } + if !mail + .ctype + .mimetype + .eq_ignore_ascii_case("multipart/encrypted") + { + log::debug!("check_encrypted: not multipart/encrypted"); + return false; + } + for (part_idx, part) in mail.subparts.iter().enumerate() { + // Each part must not be multipart + if !part.subparts.is_empty() { + log::debug!("check_encrypted: part of multipart/encrypted is itself multipart"); + return false; + } + + if part_idx == 0 { + // First part must be application/pgp-encrypted + if !part + .ctype + .mimetype + .eq_ignore_ascii_case("application/pgp-encrypted") + { + log::debug!( + "check_encrypted: first part not application/pgp-encrypted, got: {}", + part.ctype.mimetype + ); + return false; + } + + // Payload must be "Version: 1" + let payload = match part.get_body() { + Ok(p) => p, + Err(_) => { + log::debug!("check_encrypted: failed to get body of first part"); + return false; + } + }; + if payload.trim() != "Version: 1" { + log::debug!( + "check_encrypted: first part payload not 'Version: 1', got {}", + payload.trim() + ); + return false; + } + } else if part_idx == 1 { + // Second part must be application/octet-stream + if part.ctype.mimetype != "application/octet-stream" { + log::debug!( + "check_encrypted: second part not application/octet-stream, got: {}", + part.ctype.mimetype + ); + return false; + } + + // Check the armored payload + let payload = match part.get_body() { + Ok(p) => p, + Err(_) => { + log::debug!("check_encrypted: failed to get body of second part"); + return false; + } + }; + if !check_armored_payload(payload, outgoing) { + log::debug!("check_encrypted: armored payload check failed"); + return false; + } + } else { + log::debug!("check_encrypted: more than two parts found"); + return false; + } + } + + true +} + +/// Check if recipient matches a passthrough pattern +pub fn recipient_matches_passthrough(recipient: &str, passthrough_recipients: &[String]) -> bool { + for addr in passthrough_recipients { + if recipient == addr { + return true; + } + if addr.starts_with('@') && recipient.ends_with(addr) { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use mailparse::parse_mail; + use rstest::*; + use testresult::TestResult; + + #[fixture] + fn passthrough_recipients() -> Vec { + vec!["pass@example.org".to_string(), "@example.com".to_string()] + } + + #[rstest] + #[case::asm("test_data/asm.eml", false)] + #[case::encrypted("test_data/encrypted.eml", false)] + #[case::fake_encrypted("test_data/fake-encrypted.eml", false)] + #[case::literal("test_data/literal.eml", false)] + #[case::mailer_daemon("test_data/mailer-daemon.eml", false)] + #[case::mdn("test_data/mdn.eml", false)] + #[case::plain("test_data/plain.eml", false)] + #[case::securejoin_vc("test_data/securejoin-vc.eml", true)] + #[case::securejoin_vc_fake("test_data/securejoin-vc-fake.eml", false)] + fn test_is_securejoin(#[case] file: &str, #[case] expected: bool) -> TestResult { + let raw_email = std::fs::read_to_string(file)?; + let parsed = parse_mail(raw_email.as_bytes())?; + assert_eq!(is_securejoin(&parsed), expected); + Ok(()) + } + + #[rstest] + #[case::asm("test_data/asm.eml", false)] + #[case::encrypted("test_data/encrypted.eml", true)] + #[case::fake_encrypted("test_data/fake-encrypted.eml", false)] + #[case::literal("test_data/literal.eml", false)] + #[case::mailer_daemon("test_data/mailer-daemon.eml", false)] + #[case::mdn("test_data/mdn.eml", false)] + #[case::plain("test_data/plain.eml", false)] + #[case::securejoin_vc("test_data/securejoin-vc.eml", false)] + #[case::securejoin_vc_fake("test_data/securejoin-vc-fake.eml", false)] + fn test_check_encrypted(#[case] file: &str, #[case] expected: bool) -> TestResult { + let raw_email = std::fs::read_to_string(file)?; + let parsed = parse_mail(raw_email.as_bytes())?; + assert_eq!(check_encrypted(&parsed, false), expected); + Ok(()) + } + + #[rstest] + #[case("pass@example.org", true)] + #[case("other@example.org", false)] + #[case("anything@example.com", true)] + #[case("anything@sub.example.com", false)] + fn test_recipient_matches_passthrough( + #[case] recipient: &str, + #[case] expected: bool, + passthrough_recipients: Vec, + ) { + let result = recipient_matches_passthrough(recipient, &passthrough_recipients); + assert_eq!(result, expected); + } +} diff --git a/filtermail/src/openpgp.rs b/filtermail/src/openpgp.rs new file mode 100644 index 00000000..582037b6 --- /dev/null +++ b/filtermail/src/openpgp.rs @@ -0,0 +1,269 @@ +//! OpenPGP payload checker. + +use crate::error; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + +/// Tries to get the byte `$idx` of the array slice `$payload`. +/// +/// Returns [`error::Error::TruncatedHeader`] in the outer function, if `$idx` is out of range. +macro_rules! get_byte { + ($payload:expr, $idx:expr) => { + *$payload.get($idx).ok_or(error::Error::TruncatedHeader)? + }; +} + +/// Checks the OpenPGP payload. +/// +/// OpenPGP payload must consist only of `PKESK` and `SKESK` packets terminated by a single `SEIPD` packet. +/// +/// Returns `Ok(true)` if OpenPGP payload is correct, `Ok(false)` otherwise. +/// +/// # Errors +/// +/// Returns an [`error::Error::TruncatedHeader`] if the OpenPGP packet header is truncated. +fn check_openpgp_payload(payload: &[u8]) -> Result { + let mut i: usize = 0; + while i < payload.len() { + // Only OpenPGP format is allowed. + if (get_byte!(payload, i) & 0xC0) != 0xC0 { + log::debug!("check_openpgp_payload: i={i} Not OpenPGP format"); + return Ok(false); + } + + let packet_type_id = get_byte!(payload, i) & 0x3F; + i += 1; + + while get_byte!(payload, i) >= 224 && get_byte!(payload, i) < 255 { + // Partial body length. + let partial_length = 1usize << (get_byte!(payload, i) & 0x1F); + i += 1 + partial_length; + } + + let body_len: usize; + if get_byte!(payload, i) < 192 { + // One-octet length. + body_len = get_byte!(payload, i) as usize; + i += 1; + } else if get_byte!(payload, i) < 224 { + // Two-octet length. + body_len = (((get_byte!(payload, i) as usize) - 192) << 8) + + (get_byte!(payload, i + 1) as usize) + + 192; + i += 2; + } else if get_byte!(payload, i) == 255 { + // Five-octet length. + body_len = ((get_byte!(payload, i + 1) as usize) << 24) + | ((get_byte!(payload, i + 2) as usize) << 16) + | ((get_byte!(payload, i + 3) as usize) << 8) + | (get_byte!(payload, i + 4) as usize); + i += 5; + } else { + // Impossible, partial body length was processed above. + log::debug!("check_openpgp_payload: i={i} Invalid body length"); + return Ok(false); + } + + i += body_len; + + if i == payload.len() { + // Last packet should be + // Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD) + // + // This is the only place where this function may return `True`. + log::debug!( + "check_openpgp_payload: i={i} packat_type_id={}", + packet_type_id + ); + return Ok(packet_type_id == 18); + } else if ![1, 3].contains(&packet_type_id) { + // All packets except the last one must be either + // Public-Key Encrypted Session Key Packet (PKESK) + // or + // Symmetric-Key Encrypted Session Key Packet (SKESK) + log::debug!( + "check_openpgp_payload: i={i} packet_type_id={}", + packet_type_id + ); + return Ok(false); + } + } + + Ok(false) +} + +/// Check the armored PGP message for invalid content. +/// +/// Returns `true` if the `payload` is a valid PGP message, +/// `outgoing` informs whether the message is outgoing or incoming +pub fn check_armored_payload(mut payload: String, outgoing: bool) -> bool { + const PREFIX: &str = "-----BEGIN PGP MESSAGE-----\r\n"; + if !payload.starts_with(PREFIX) { + log::debug!("check_armored_payload: Did not find PGP MESSAGE prefix"); + return false; + } + payload = payload[PREFIX.len()..].to_string(); + + while payload.ends_with("\r\n") { + payload.truncate(payload.len() - 2); + } + const SUFFIX: &str = "-----END PGP MESSAGE-----"; + if !payload.ends_with(SUFFIX) { + log::debug!("check_armored_payload: Did not find PGP MESSAGE suffix"); + return false; + } + payload.truncate(payload.len() - SUFFIX.len()); + + const VERSION_COMMENT: &str = "Version: "; + if payload.starts_with(VERSION_COMMENT) { + // Disallow comments in outgoing messages + if outgoing { + log::debug!("check_armored_payload: Comment found in outgoing message"); + return false; + } + // Remove comments from incoming messages + if let Some((_, right)) = payload.split_once("\r\n") { + payload = right.to_string(); + } + } + + while payload.starts_with("\r\n") { + payload = payload[2..].to_string(); + } + + // Remove CRC24. + if let Some((left, _)) = payload.rsplit_once('=') { + payload = left.to_string(); + } + + payload = payload.replace(['\r', '\n'], ""); + let payload = match BASE64_STANDARD.decode(payload.as_bytes()) { + Ok(v) => v, + Err(_) => { + log::debug!("check_armored_payload: Base64 decoding failed"); + return false; + } + }; + + check_openpgp_payload(&payload).unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::*; + + #[rstest] + #[case::valid(r#"-----BEGIN PGP MESSAGE----- + +wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg +O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae +8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI +JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no +lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz +ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM +YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA +kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI ++lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg +RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo +tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7 +rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp +H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI +fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9 +61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN +XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3 +w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb +NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs +baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW +A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8 +uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI +E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn +lkOWnEbCD+XTnbDd +=agR5 +-----END PGP MESSAGE-----"#, (true, true))] + #[case::with_comment(r#"-----BEGIN PGP MESSAGE----- +Version: 1 +wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg +O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae +8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI +JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no +lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz +ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM +YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA +kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI ++lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg +RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo +tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7 +rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp +H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI +fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9 +61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN +XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3 +w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb +NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs +baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW +A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8 +uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI +E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn +lkOWnEbCD+XTnbDd +=agR5 +-----END PGP MESSAGE-----"#, (false, true))] + #[case::invalid_base64(r#"-----BEGIN PGP MESSAGE----- + +wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg +O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae +8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI +JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no +lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz +ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM +YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA +kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI ++lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg +RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo +tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7 +rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp +H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI +fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9 +61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN +XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3 +w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb +NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs +baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW +A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8 +uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI +E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn +lkOWnEbCD+XTnbDd= +=agR5 +-----END PGP MESSAGE-----"#, (false, false))] + #[case::invalid_non_pgp_base64(r#"-----BEGIN PGP MESSAGE----- + +RGVsdGEgQ2hhdCBpcyBhIHJlbGlhYmxlLCBkZWNlbnRyYWxpemVkIGFuZCBzZWN1cmUgaW5zdGFu +dCBtZXNzYWdpbmcgYXBwLCBhdmFpbGFibGUgZm9yIG1vYmlsZSBhbmQgZGVza3RvcCBwbGF0Zm9y +bXMuCgogICAgSW5zdGFudCBjcmVhdGlvbiBvZiBwcml2YXRlIGNoYXQgcHJvZmlsZXMgd2l0aCBz +ZWN1cmUgYW5kIGludGVyb3BlcmFibGUgY2hhdG1haWwgcmVsYXlzIHRoYXQgb2ZmZXIgaW5zdGFu +dCBtZXNzYWdlIGRlbGl2ZXJ5LCBhbmQgUHVzaCBOb3RpZmljYXRpb25zIGZvciBpT1MgYW5kIEFu +ZHJvaWQgZGV2aWNlcy4KCiAgICBQZXJ2YXNpdmUgbXVsdGktcHJvZmlsZSBhbmQgbXVsdGktZGV2 +aWNlIHN1cHBvcnQgb24gYWxsIHBsYXRmb3JtcyBhbmQgYmV0d2VlbiBkaWZmZXJlbnQgY2hhdG1h +aWwgYXBwcy4KCiAgICBJbnRlcmFjdGl2ZSBpbi1jaGF0IGFwcHMgZm9yIGdhbWluZyBhbmQgY29s +bGFib3JhdGlvbgoKICAgIEF1ZGl0ZWQgZW5kLXRvLWVuZCBlbmNyeXB0aW9uIHNhZmUgYWdhaW5z +dCBuZXR3b3JrIGFuZCBzZXJ2ZXIgYXR0YWNrcy4KCiAgICBGcmVlIGFuZCBPcGVuIFNvdXJjZSBz +b2Z0d2FyZSwgYm90aCBhcHAgYW5kIHNlcnZlciBzaWRlLCBidWlsdCBvbiBJbnRlcm5ldCBTdGFu +ZGFyZHMuCgo= +=4cf0a3 +-----END PGP MESSAGE-----"#, (false, false))] + #[case::invalid_cleartext(r#"-----BEGIN PGP MESSAGE----- + +Definitely not base64 encoded PGP message content. +-----END PGP MESSAGE-----"#, (false, false))] + #[case::invalid_no_begin(r#"-----END PGP MESSAGE-----"#, (false, false))] + #[case::invalid_no_end(r#"-----BEGIN PGP MESSAGE-----"#, (false, false))] + fn test_check_armored_payload(#[case] pgp_message: &str, #[case] expected: (bool, bool)) { + let (expected_outgoing, expected_incoming) = expected; + + let result = check_armored_payload(pgp_message.replace('\n', "\r\n").to_string(), true); + assert_eq!(result, expected_outgoing); + + let result = check_armored_payload(pgp_message.replace('\n', "\r\n").to_string(), false); + assert_eq!(result, expected_incoming); + } +} diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs new file mode 100644 index 00000000..dee3e60a --- /dev/null +++ b/filtermail/src/outbound.rs @@ -0,0 +1,145 @@ +//! Module for handling outgoing SMTP messages. + +use crate::ENCRYPTION_NEEDED_523; +use crate::config::Config; +use crate::message::{check_encrypted, is_securejoin, recipient_matches_passthrough}; +use crate::rate_limiter::SendRateLimiter; +pub use crate::smtp_server::Envelope; +use crate::smtp_server::SmtpHandler; +use crate::utils::{extract_address, format_smtp_error}; +use async_trait::async_trait; +use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; +use mailparse::{MailHeaderMap, parse_mail}; +use std::sync::{Arc, Mutex}; + +/// Handler for outgoing SMTP messages. +pub struct OutgoingBeforeQueueHandler { + config: Arc, + send_rate_limiter: Arc>, +} + +impl OutgoingBeforeQueueHandler { + pub fn new(config: Config) -> Self { + Self { + config: Arc::new(config), + send_rate_limiter: Arc::new(Mutex::new(SendRateLimiter::default())), + } + } +} + +#[async_trait] +impl SmtpHandler for OutgoingBeforeQueueHandler { + fn handle_mail(&self, address: &str) -> Result<(), String> { + log::info!("handle_MAIL from {}", address); + + let parts: Vec<&str> = address.split('@').collect(); + if parts.len() != 2 { + return Err(format!("500 Invalid from address <{}>", address)); + } + + let max_sent = self.config.max_user_send_per_minute; + let mut limiter = self.send_rate_limiter.lock().unwrap(); + if !limiter.is_sending_allowed(address, max_sent) { + log::debug!("Rate limit exceeded for {}", address); + return Err(format!("450 4.7.1: Too much mail from {}", address)); + } + + Ok(()) + } + + fn check_data(&self, envelope: &Envelope) -> Result<(), String> { + log::info!("Processing DATA message from {}", envelope.mail_from); + + let message = match parse_mail(&envelope.data) { + Ok(m) => m, + Err(e) => return Err(format!("500 Failed to parse message: {}", e)), + }; + + let mail_encrypted = check_encrypted(&message, true); + + let from_header = message + .headers + .get_first_value("From") + .unwrap_or_default() + .trim() + .to_string(); + + let from_addr = extract_address(&from_header) + .ok_or(format!("500 Invalid FROM header: {from_header}"))?; + + if !envelope.mail_from.eq_ignore_ascii_case(&from_addr) { + return Err(format!( + "500 Invalid FROM <{}> for <{}>", + from_addr, envelope.mail_from + )); + } + + // Allow encrypted or securejoin messages + if mail_encrypted || is_securejoin(&message) { + log::info!("Outgoing: Filtering encrypted mail."); + return Ok(()); + } + + log::info!("Outgoing: Filtering unencrypted mail."); + + // Allow passthrough senders + if self + .config + .passthrough_senders + .contains(&envelope.mail_from) + { + return Ok(()); + } + + // Allow self-sent Autocrypt Setup Message + if envelope.rcpt_to.len() == 1 && envelope.rcpt_to[0] == envelope.mail_from { + let subject = message + .headers + .get_first_value("Subject") + .unwrap_or_default(); + if subject == "Autocrypt Setup Message" && message.ctype.mimetype == "multipart/mixed" { + return Ok(()); + } + } + + for recipient in &envelope.rcpt_to { + if !recipient_matches_passthrough(recipient, &self.config.passthrough_recipients) { + log::info!("Rejected unencrypted mail."); + return Err(ENCRYPTION_NEEDED_523.to_string()); + } + } + + Ok(()) + } + + async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { + log::info!("Re-injecting the mail that passed checks"); + + let mailer = AsyncSmtpTransport::::builder_dangerous("localhost") + .port(self.config.postfix_reinject_port) + .build(); + + let envelope_data = lettre::address::Envelope::new( + Some( + envelope + .mail_from + .parse() + .map_err(|e| format!("Invalid from address: {}", e))?, + ), + envelope + .rcpt_to + .iter() + .map(|addr| addr.parse()) + .collect::, _>>() + .map_err(|e| format!("Invalid to address: {}", e))?, + ) + .map_err(|e| format!("Failed to create envelope: {}", e))?; + + mailer + .send_raw(&envelope_data, &envelope.data) + .await + .map_err(format_smtp_error)?; + + Ok(()) + } +} diff --git a/filtermail/src/rate_limiter.rs b/filtermail/src/rate_limiter.rs new file mode 100644 index 00000000..00bbb13a --- /dev/null +++ b/filtermail/src/rate_limiter.rs @@ -0,0 +1,35 @@ +//! Module for rate limiting. + +use std::collections::HashMap; +use std::time::{Duration, SystemTime}; + +const ONE_MINUTE: Duration = Duration::from_secs(60); + +/// A rate limiter tracking send timestamps per address. +#[derive(Debug, Default)] +pub struct SendRateLimiter { + address_timestamps: HashMap>, +} + +impl SendRateLimiter { + pub fn is_sending_allowed(&mut self, mail_from: &str, max_send_per_minute: usize) -> bool { + self.address_timestamps.retain(|_, timestamps| { + timestamps + .last() + .map(|t| t.elapsed().unwrap_or_default() <= ONE_MINUTE) + .unwrap_or(false) + }); + + let last = self + .address_timestamps + .entry(mail_from.to_string()) + .or_default(); + last.retain(|&send_time| send_time.elapsed().unwrap_or_default() <= ONE_MINUTE); + if last.len() <= max_send_per_minute { + last.push(SystemTime::now()); + true + } else { + false + } + } +} diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs new file mode 100644 index 00000000..06e1e9af --- /dev/null +++ b/filtermail/src/smtp_server.rs @@ -0,0 +1,196 @@ +//! A simplified SMTP server implementation for internal communication. + +use crate::utils::extract_address; +use async_trait::async_trait; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{TcpListener, TcpStream}; + +/// Represents an SMTP envelope with sender, recipients, and raw message data. +#[derive(Debug, Clone)] +pub struct Envelope { + pub mail_from: String, + pub rcpt_to: Vec, + pub data: Vec, +} + +/// Trait defining the SMTP handler interface. +#[async_trait] +pub trait SmtpHandler: Send + Sync { + /// Handles the MAIL FROM command. + fn handle_mail(&self, address: &str) -> Result<(), String>; + + /// Checks the DATA command before reinjection. + fn check_data(&self, envelope: &Envelope) -> Result<(), String>; + + /// Reinjects the mail back to postfix. + async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String>; + + /// Handles the DATA command. + async fn handle_data(&self, envelope: &Envelope) -> Result { + log::info!("handle_DATA before-queue"); + self.check_data(envelope)?; + self.reinject_mail(envelope).await.map_err(|e| { + log::warn!("Failed to reinject mail: {}", e); + e + })?; + Ok("250 OK".to_string()) + } +} + +/// Runs the SMTP server on the specified address with the given handler and maximum message size. +pub async fn run_smtp_server( + addr: &str, + handler: Arc, + max_size: usize, +) -> Result<(), Box> +where + H: SmtpHandler + 'static, +{ + let listener = TcpListener::bind(addr).await?; + // message for backward compatibility with chatmaild tests. + log::info!("entering serving loop"); + + loop { + let (socket, _) = listener.accept().await?; + let handler = handler.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(socket, handler, max_size).await { + log::error!("Error handling connection: {}", e); + } + }); + } +} + +/// Handles an individual SMTP connection. +async fn handle_connection( + socket: TcpStream, + handler: Arc, + max_size: usize, +) -> Result<(), Box> +where + H: SmtpHandler, +{ + let (reader, mut writer) = socket.into_split(); + let mut reader = BufReader::new(reader); + let mut line = String::new(); + + writer.write_all(b"220 filtermail SMTP\r\n").await?; + + let mut envelope = Envelope { + mail_from: String::new(), + rcpt_to: Vec::new(), + data: Vec::new(), + }; + + 'connection: loop { + line.clear(); + let n = reader.read_line(&mut line).await?; + if n == 0 { + break 'connection; + } + + // Remove CRLF + // Note: this will kill the connection if any line doesn't end with CRLF. + // This is intentional as stray LF most likely means an attempt to exploit the server. + let Some(cmd) = line.strip_suffix("\r\n") else { + log::warn!("Malformed command without CRLF ending! Closing connection."); + break 'connection; + }; + + log::debug!("Received: {}", cmd); + + if cmd.to_uppercase().starts_with("HELO") || cmd.to_uppercase().starts_with("EHLO") { + writer.write_all(b"250 OK\r\n").await?; + } else if cmd.to_uppercase().starts_with("MAIL FROM:") { + if let Some(from) = extract_address(cmd) { + match handler.handle_mail(&from) { + Ok(_) => { + envelope.mail_from = from; + writer.write_all(b"250 OK\r\n").await?; + } + Err(e) => { + writer.write_all(format!("{}\r\n", e).as_bytes()).await?; + break 'connection; + } + } + } else { + log::debug!("Invalid MAIL FROM command. Can't extract address."); + writer + .write_all(b"500 Invalid addreess in MAIL FROM\r\n") + .await?; + } + } else if cmd.to_uppercase().starts_with("RCPT TO:") { + if let Some(to) = extract_address(cmd) { + envelope.rcpt_to.push(to); + writer.write_all(b"250 OK\r\n").await?; + } + } else if cmd.to_uppercase().starts_with("DATA") { + writer + .write_all(b"354 End data with .\r\n") + .await?; + let mut data = Vec::new(); + let mut data_line = String::new(); + 'data_read: loop { + data_line.clear(); + reader.read_line(&mut data_line).await?; + + if data_line == ".\r\n" { + break 'data_read; + } + + if !data_line.ends_with("\r\n") { + log::warn!("Malformed DATA line without CRLF ending! Closing connection."); + break 'connection; + } + + data.extend_from_slice(data_line.as_bytes()); + + if data.len() > max_size { + writer + .write_all(b"552 Message exceeds maximum size\r\n") + .await?; + break 'connection; + } + } + + envelope.data = data; + + // Process the message + match handler.handle_data(&envelope).await { + Ok(response) => { + log::debug!("Sent: {}", response); + writer + .write_all(format!("{}\r\n", response).as_bytes()) + .await?; + } + Err(e) => { + log::debug!("Sent: {}", e); + writer.write_all(format!("{}\r\n", e).as_bytes()).await?; + } + } + + envelope = Envelope { + mail_from: String::new(), + rcpt_to: Vec::new(), + data: Vec::new(), + }; + } else if cmd.to_uppercase().starts_with("QUIT") { + writer.write_all(b"221 OK\r\n").await?; + break 'connection; + } else if cmd.to_uppercase().starts_with("RSET") { + envelope = Envelope { + mail_from: String::new(), + rcpt_to: Vec::new(), + data: Vec::new(), + }; + writer.write_all(b"250 OK\r\n").await?; + } else if cmd.to_uppercase().starts_with("NOOP") { + writer.write_all(b"250 OK\r\n").await?; + } else { + writer.write_all(b"500 Command not recognized\r\n").await?; + } + } + + Ok(()) +} diff --git a/filtermail/src/utils.rs b/filtermail/src/utils.rs new file mode 100644 index 00000000..526339e1 --- /dev/null +++ b/filtermail/src/utils.rs @@ -0,0 +1,74 @@ +use mailparse::MailAddr; +use std::error::Error; + +/// Extracts the first email address found in SMTP command or email header. +/// +/// Return `None` if parsing fails. +/// +/// Returns the first address if multiple are present. +pub fn extract_address(input: &str) -> Option { + // TODO: at this point it's probably simpler to use regex ;p + let input_lower = input.to_lowercase(); + let mut trimmed = input_lower + .trim_start_matches("mail from:") + .trim_start_matches("rcpt to:"); + trimmed = trimmed + .split_once("=") + .map(|(address_raw, _)| { + address_raw + .rsplit_once(' ') + .map(|(addr, _)| addr) + .unwrap_or(address_raw) + .trim() + }) + .unwrap_or(trimmed); + + mailparse::addrparse(trimmed) + .ok() + .and_then(|addr| match addr.first() { + Some(MailAddr::Single(single)) => Some(single.addr.clone()), + Some(MailAddr::Group(group)) => group.addrs.first().map(|single| single.addr.clone()), + None => None, + }) +} + +/// Formats SMTP error to be able to send it back to postfix. +pub fn format_smtp_error(error: lettre::transport::smtp::Error) -> String { + if let Some(code) = error.status() { + format!( + "{} {}", + code, + error + .source() + .map(ToString::to_string) + .unwrap_or("Unknown error".to_string()) + ) + } else { + // Default to 451, most probably means some internal service error (e.g. milter) + format!( + "451 {}", + error + .source() + .map(ToString::to_string) + .unwrap_or("Unknown error".to_string()) + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::*; + + #[rstest] + #[case("MAIL FROM:", Some("t1@example.org".to_string()))] + #[case("MAIL FROM: SOMETHING=SOMETHING OTHER=OTHER", Some("t2@example.org".to_string()))] + #[case("RCPT TO:", Some("t3@example.org".to_string()))] + #[case("mail from:", Some("t4@example.org".to_string()))] + #[case("Foo Bar ", Some("t5@example.org".to_string()))] + #[case("t6@example.org", Some("t6@example.org".to_string()))] + fn test_extract_address(#[case] input: &str, #[case] expected: Option) { + let result = extract_address(input); + assert_eq!(result, expected) + } +} diff --git a/filtermail/test_data/asm.eml b/filtermail/test_data/asm.eml new file mode 100644 index 00000000..d95bb683 --- /dev/null +++ b/filtermail/test_data/asm.eml @@ -0,0 +1,56 @@ +From: one@example.org +To: two@example.org +Autocrypt-Setup-Message: v1 +Subject: Autocrypt Setup Message +Date: Tue, 22 Jan 2019 12:56:29 +0100 +Content-type: multipart/mixed; boundary="Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ" + +--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ +Content-Type: text/plain + +This message contains all information to transfer your Autocrypt +settings along with your secret key securely from your original +device. + +To set up your new device for Autocrypt, please follow the +instuctions that should be presented by your new device. + +You can keep this message and use it as a backup for your secret +key. If you want to do this, you should write down the Setup Code +and store it securely. +--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ +Content-Type: application/autocrypt-setup +Content-Disposition: attachment; filename="autocrypt-setup-message.html" + + +

+This is the Autocrypt setup file used to transfer settings and +keys between clients. You can decrypt it using the Setup Code +presented on your old device, and then import the contained key +into your keyring. +

+ +
+-----BEGIN PGP MESSAGE-----
+Passphrase-Format: numeric9x4
+Passphrase-Begin: 17
+
+jA0EBwMCFAxADoCdzeX/0ukBlqI5+pfpKb751qd/7nLNbkpy3gVcaf1QwRPZYt40
+Ynp08UqRQ2g48ZlnzHLSwlTGOPTuv2Jt8ka+pgZ45xzvJSG2gau03xP4VsC271kR
+VmCjdb0Y6Rk96mAwfGzrkbaRQ9Z7fIoL866GOv6h9neiVIkp+JYlTV6ISD0ZQJ4Q
+I6dOQkB/TWZyVjtiJDOQHdfNWliA6NtqaLq19wlu9L5xXjuNpY95KwR8EJXWe0+o
+Y3d2U/KxOAkXKghP2Qg1GtlPVeGC5T4p03TGI6pzKT+kHX6Rrm9wK6sM9aTquMmF
+Vok84Jg1DFnwivWC2RILR81rXi7k/+Y6MUbveFgJ9cQduqpxnmD7TjOblYu7M6zp
+YGAUxh8DRKlIMn2QsA++DBYQ6ACZvwuY8qTDLkqPDo4WqM313dsMJbyGjDdVE7EM
+PESS+RlABETpZXz8g/ycr6DIUNdlbPcmYlsBfHWDOuR2GFFTwmlv5slWS39dJv38
+E0eIe1CwdxI801Se7t7dUUS/ZF8wb6GlmxOcqGbF8eko1Z0S64IAm7/h13MRQCxI
+geQnHfGYVJ2FOimoCMEKwfa9x++RFTDW0u7spDC2uWvK/1viV8OfRppFhLr/kmKb
+18lWXuAz80DAjUDUsVqEq2MvJBJGoCJUEyjuRsLkHYRM5jYk4v50LyyR0Om73nWF
+nZBqmqNzdr7Xb9PHHdFhnEc0VvoYbrcM0RVYcEMW3YbmejM891j1d6Iv+/n/qND/
+NdebGrfWJMmFLf/iEkzTZ3/v5inW9LpWoRc94ioCjJTaEo8Rib6ARRFaJVIsmNXi
+YicFGO98D+zX+a2t9Yz6IpPajVslnOp6ScpmXgts/2XWD7oE+JgxSAqo/dLVsHgP
+Ufo=
+=pulM
+-----END PGP MESSAGE-----
+
+--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ-- diff --git a/filtermail/test_data/encrypted.eml b/filtermail/test_data/encrypted.eml new file mode 100644 index 00000000..889c1cde --- /dev/null +++ b/filtermail/test_data/encrypted.eml @@ -0,0 +1,66 @@ +From: one@example.org +To: two@example.org +Subject: {subject} +Date: Sun, 15 Oct 2023 16:43:21 +0000 +Message-ID: +In-Reply-To: +References: + +Chat-Version: 1.0 +Autocrypt: addr=one@example.org; prefer-encrypt=mutual; + keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH + rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID + FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW + XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK + KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ + JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP + IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO +MIME-Version: 1.0 +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="YFrteb74qSXmggbOxZL9dRnhymywAi" + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: PGP/MIME version identification +Content-Type: application/pgp-encrypted + +Version: 1 + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc"; +Content-Type: application/octet-stream; name="encrypted.asc" + +-----BEGIN PGP MESSAGE----- + +wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg +O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae +8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI +JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no +lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz +ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM +YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA +kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI ++lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg +RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo +tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7 +rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp +H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI +fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9 +61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN +XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3 +w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb +NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs +baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW +A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8 +uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI +E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn +lkOWnEbCD+XTnbDd +=agR5 +-----END PGP MESSAGE----- + + +--YFrteb74qSXmggbOxZL9dRnhymywAi-- + + diff --git a/filtermail/test_data/fake-encrypted.eml b/filtermail/test_data/fake-encrypted.eml new file mode 100644 index 00000000..b612509e --- /dev/null +++ b/filtermail/test_data/fake-encrypted.eml @@ -0,0 +1,25 @@ +Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?= +Chat-Disposition-Notification-To: foobar@c2.testrun.org +Chat-User-Avatar: 0 +From: +To: +Date: Sun, 15 Oct 2023 16:41:44 +0000 +Message-ID: +References: +Chat-Version: 1.0 +Autocrypt: addr=one@example.org; prefer-encrypt=mutual; + keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m + nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID + FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I + HjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK + KwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ + JlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI + vYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no + +-----BEGIN PGP MESSAGE----- +Meow! +-----END PGP MESSAGE----- + + diff --git a/filtermail/test_data/literal.eml b/filtermail/test_data/literal.eml new file mode 100644 index 00000000..817e9c84 --- /dev/null +++ b/filtermail/test_data/literal.eml @@ -0,0 +1,44 @@ +From: one@example.org +To: two@example.org +Subject: ... +Date: Sun, 15 Oct 2023 16:43:21 +0000 +Message-ID: +In-Reply-To: +References: + +Chat-Version: 1.0 +Autocrypt: addr=one@example.org; prefer-encrypt=mutual; + keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH + rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID + FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW + XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK + KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ + JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP + IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO +MIME-Version: 1.0 +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="YFrteb74qSXmggbOxZL9dRnhymywAi" + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: PGP/MIME version identification +Content-Type: application/pgp-encrypted + +Version: 1 + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc"; +Content-Type: application/octet-stream; name="encrypted.asc" + +-----BEGIN PGP MESSAGE----- + +yxJiAAAAAABIZWxsbyB3b3JsZCE= +=1I/B +-----END PGP MESSAGE----- + + +--YFrteb74qSXmggbOxZL9dRnhymywAi-- + + diff --git a/filtermail/test_data/mailer-daemon.eml b/filtermail/test_data/mailer-daemon.eml new file mode 100644 index 00000000..66e7c7c2 --- /dev/null +++ b/filtermail/test_data/mailer-daemon.eml @@ -0,0 +1,46 @@ +Date: Fri, 8 Jul 1994 09:21:47 -0400 +From: Mail Delivery Subsystem +Subject: Returned mail: User unknown +To: +Auto-Submitted: auto-replied +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=delivery-status; + boundary="JAA13167.773673707/CS.UTK.EDU" + +--JAA13167.773673707/CS.UTK.EDU +content-type: text/plain; charset=us-ascii + + ----- The following addresses had delivery problems ----- + (unrecoverable error) + (unrecoverable error) + +--JAA13167.773673707/CS.UTK.EDU +content-type: message/delivery-status + +Reporting-MTA: dns; cs.utk.edu + +Original-Recipient: rfc822;arathib@vnet.ibm.com +Final-Recipient: rfc822;arathib@vnet.ibm.com +Action: failed +Status: 5.0.0 (permanent failure) +Diagnostic-Code: smtp; + 550 'arathib@vnet.IBM.COM' is not a registered gateway user +Remote-MTA: dns; vnet.ibm.com + +Original-Recipient: rfc822;johnh@hpnjld.njd.hp.com +Final-Recipient: rfc822;johnh@hpnjld.njd.hp.com +Action: delayed +Status: 4.0.0 (hpnjld.njd.jp.com: host name lookup failure) + +Original-Recipient: rfc822;wsnell@sdcc13.ucsd.edu +Final-Recipient: rfc822;wsnell@sdcc13.ucsd.edu +Action: failed +Status: 5.0.0 +Diagnostic-Code: smtp; 550 user unknown +Remote-MTA: dns; sdcc13.ucsd.edu + +--JAA13167.773673707/CS.UTK.EDU +content-type: message/rfc822 + +[original message goes here] +--JAA13167.773673707/CS.UTK.EDU-- diff --git a/filtermail/test_data/mdn.eml b/filtermail/test_data/mdn.eml new file mode 100644 index 00000000..16fbdf38 --- /dev/null +++ b/filtermail/test_data/mdn.eml @@ -0,0 +1,33 @@ +Subject: Message opened +From: +To: +Date: Sun, 15 Oct 2023 16:43:25 +0000 +Message-ID: +Auto-Submitted: auto-replied +Chat-Version: 1.0 +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=disposition-notification; + boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi" + + +--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi +Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no + +The "Hi!" message you sent was displayed on the screen of the recipient. + +This is no guarantee the content was read. + + +--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi +Content-Type: message/disposition-notification + +Reporting-UA: Delta Chat 1.124.1 +Original-Recipient: rfc822;barbaz@c2.testrun.org +Final-Recipient: rfc822;barbaz@c2.testrun.org +Original-Message-ID: +Disposition: manual-action/MDN-sent-automatically; displayed + + +--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi-- + + diff --git a/filtermail/test_data/plain.eml b/filtermail/test_data/plain.eml new file mode 100644 index 00000000..6c44f1ed --- /dev/null +++ b/filtermail/test_data/plain.eml @@ -0,0 +1,23 @@ +Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?= +Chat-Disposition-Notification-To: foobar@c2.testrun.org +Chat-User-Avatar: 0 +From: +To: +Date: Sun, 15 Oct 2023 16:41:44 +0000 +Message-ID: +References: +Chat-Version: 1.0 +Autocrypt: addr=one@example.org; prefer-encrypt=mutual; + keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m + nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID + FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I + HjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK + KwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ + JlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI + vYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no + +Meow! + + diff --git a/filtermail/test_data/securejoin-vc-fake.eml b/filtermail/test_data/securejoin-vc-fake.eml new file mode 100644 index 00000000..8051009e --- /dev/null +++ b/filtermail/test_data/securejoin-vc-fake.eml @@ -0,0 +1,21 @@ +Subject: Message from one@example.org +From: +To: +Date: Sun, 15 Oct 2023 16:43:25 +0000 +Message-ID: +Chat-Version: 1.0 +Secure-Join: vc-request +Secure-Join-Invitenumber: RANDOM-TOKEN +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi" + + +--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi +Content-Type: text/plain; charset=utf-8 + +Meow! + + +--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi-- + + diff --git a/filtermail/test_data/securejoin-vc.eml b/filtermail/test_data/securejoin-vc.eml new file mode 100644 index 00000000..9503a8a5 --- /dev/null +++ b/filtermail/test_data/securejoin-vc.eml @@ -0,0 +1,21 @@ +Subject: Message from one@example.org +From: +To: +Date: Sun, 15 Oct 2023 16:43:25 +0000 +Message-ID: +Chat-Version: 1.0 +Secure-Join: vc-request +Secure-Join-Invitenumber: RANDOM-TOKEN +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi" + + +--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi +Content-Type: text/plain; charset=utf-8 + +Secure-Join: vc-request + + +--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi-- + + From 5f4399f54da8fcfac2ec6a6bab775af42bf3c224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Fri, 16 Jan 2026 15:10:17 +0100 Subject: [PATCH 003/166] chore(dependabot): Setup dependabot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/.github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 filtermail/.github/dependabot.yml diff --git a/filtermail/.github/dependabot.yml b/filtermail/.github/dependabot.yml new file mode 100644 index 00000000..5251d0f3 --- /dev/null +++ b/filtermail/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "cargo" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" From f9abff076414700ef3d2bd5b291beaad58338e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Fri, 16 Jan 2026 15:14:36 +0100 Subject: [PATCH 004/166] chore(ci): Setup CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/.github/workflows/ci.yml | 77 +++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 filtermail/.github/workflows/ci.yml diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml new file mode 100644 index 00000000..1d692c23 --- /dev/null +++ b/filtermail/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +permissions: {} + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + fmt: + name: Check Formatting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # master + with: + toolchain: nightly + components: rustfmt + - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2 + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 + with: + tool: taplo-cli + - run: taplo format --check && cargo fmt --check + + cargo-machete: + name: Check Unused Dependencies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - uses: bnjbvr/cargo-machete@7959c845782fed02ee69303126d4a12d64f1db18 # v0.9.1 + + lint-clippy: + name: Check Clippy + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + toolchain: ["stable", "beta"] + continue-on-error: ${{ matrix.toolchain == 'beta' }} + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # master + with: + toolchain: ${{ matrix.toolchain }} + components: clippy + - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2 + - run: cargo clippy + env: + RUSTUP_TOOLCHAIN: ${{ matrix.toolchain }} + + test: + name: Test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # master + with: + toolchain: stable + - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2 + - run: cargo test From 401fd39ec0196ef758a0a4cf858eb8822caf00eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Fri, 16 Jan 2026 15:16:38 +0100 Subject: [PATCH 005/166] chore(dependabot): Add github-actions to dependabot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/.github/dependabot.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/filtermail/.github/dependabot.yml b/filtermail/.github/dependabot.yml index 5251d0f3..5d0ffc61 100644 --- a/filtermail/.github/dependabot.yml +++ b/filtermail/.github/dependabot.yml @@ -1,11 +1,11 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - version: 2 updates: - - package-ecosystem: "cargo" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" schedule: interval: "weekly" From 392def1322b03d342356536acf6c0907aa359388 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Fri, 16 Jan 2026 15:32:47 +0100 Subject: [PATCH 006/166] docs(readme): Add README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 filtermail/README.md diff --git a/filtermail/README.md b/filtermail/README.md new file mode 100644 index 00000000..5e37f97c --- /dev/null +++ b/filtermail/README.md @@ -0,0 +1,9 @@ +# filtermail + +Rust drop-in reimplementation of chatmaild's filtermail. + +## Usage + +```plain +filtermail (incoming|outgoing) +``` From d25faf4fdf94fe6d3d859cec45e66b083f7f6a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Fri, 16 Jan 2026 15:38:18 +0100 Subject: [PATCH 007/166] docs(license): Add LICENSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/Cargo.toml | 1 + filtermail/LICENSE | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 filtermail/LICENSE diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 4ac61b7e..c95259a1 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -2,6 +2,7 @@ name = "filtermail" version = "0.1.0" edition = "2024" +license = "MIT" [dependencies] async-trait = "0.1.89" diff --git a/filtermail/LICENSE b/filtermail/LICENSE new file mode 100644 index 00000000..765a23dd --- /dev/null +++ b/filtermail/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2026, chatmail and delta chat teams + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file From d2cca26b1ccac8016ea3d4b4ad723e63757a4485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Fri, 16 Jan 2026 15:48:06 +0100 Subject: [PATCH 008/166] chore(cargo): Add metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/Cargo.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index c95259a1..14919b42 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -3,6 +3,10 @@ name = "filtermail" version = "0.1.0" edition = "2024" license = "MIT" +readme = "README.md" +authors = ["Chatmail Developers"] +description = "A mail filtering service used by chatmail relay" +repository = "https://github.com/chatmail/filtermail" [dependencies] async-trait = "0.1.89" From c78df0009bf2f35138f80c17ba4467ee3e978225 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:53:01 +0100 Subject: [PATCH 009/166] chore(deps): Bump taiki-e/install-action from 2.62.49 to 2.66.7 (#2) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.62.49 to 2.66.7. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/44c6d64aa62cd779e873306675c7a58e86d6d532...542cebaaed782771e619bd5609d97659d109c492) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.66.7 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 1d692c23..2eb83478 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2 - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 + - uses: taiki-e/install-action@542cebaaed782771e619bd5609d97659d109c492 # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check From 00fb3d8156e6662245a44049f51652cdb86a1305 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:53:52 +0100 Subject: [PATCH 010/166] chore(deps): Bump actions/checkout from 5.0.0 to 6.0.1 (#6) Bumps [actions/checkout](https://github.com/actions/checkout) from 5.0.0 to 6.0.1. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/08c6903cd8c0fde910a37f88322edcfb5dd907a8...8e8c483db84b4bee98b60c0593521ed34d9990e8) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 2eb83478..450307a3 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: name: Check Formatting runs-on: ubuntu-latest steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # master @@ -33,7 +33,7 @@ jobs: name: Check Unused Dependencies runs-on: ubuntu-latest steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - uses: bnjbvr/cargo-machete@7959c845782fed02ee69303126d4a12d64f1db18 # v0.9.1 @@ -47,7 +47,7 @@ jobs: toolchain: ["stable", "beta"] continue-on-error: ${{ matrix.toolchain == 'beta' }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # master @@ -67,7 +67,7 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macos-latest] steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # master From c15f18685afce30dcf109ed9bbadbc531877813d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:55:24 +0100 Subject: [PATCH 011/166] chore(deps): Bump thiserror from 2.0.17 to 2.0.18 (#3) Bumps [thiserror](https://github.com/dtolnay/thiserror) from 2.0.17 to 2.0.18. - [Release notes](https://github.com/dtolnay/thiserror/releases) - [Commits](https://github.com/dtolnay/thiserror/compare/2.0.17...2.0.18) --- updated-dependencies: - dependency-name: thiserror dependency-version: 2.0.18 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/Cargo.lock | 8 ++++---- filtermail/Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 5e72b821..5c91650b 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -912,18 +912,18 @@ checksum = "614b328ff036a4ef882c61570f72918f7e9c5bee1da33f8e7f91e01daee7e56c" [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 14919b42..26194a67 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -14,7 +14,7 @@ base64 = "0.22.1" serde = { version = "1.0.228", features = ["derive"] } serini = "0.2.2" tokio = { version = "1.49.0", features = ["full"] } -thiserror = "2.0.17" +thiserror = "2.0.18" mailparse = "0.16.1" lettre = { version = "0.11.19", default-features = false, features = [ "smtp-transport", From 86f86515beecda72a922dfd265d8f93743b5d2e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:56:49 +0100 Subject: [PATCH 012/166] chore(deps): Bump Swatinem/rust-cache from 2.8.1 to 2.8.2 (#5) Bumps [Swatinem/rust-cache](https://github.com/swatinem/rust-cache) from 2.8.1 to 2.8.2. - [Release notes](https://github.com/swatinem/rust-cache/releases) - [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md) - [Commits](https://github.com/swatinem/rust-cache/compare/f13886b937689c021905a6b90929199931d60db1...779680da715d629ac1d338a641029a2f4372abb5) --- updated-dependencies: - dependency-name: Swatinem/rust-cache dependency-version: 2.8.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 450307a3..db376dc7 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: with: toolchain: nightly components: rustfmt - - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - uses: taiki-e/install-action@542cebaaed782771e619bd5609d97659d109c492 # v2 with: tool: taplo-cli @@ -54,7 +54,7 @@ jobs: with: toolchain: ${{ matrix.toolchain }} components: clippy - - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - run: cargo clippy env: RUSTUP_TOOLCHAIN: ${{ matrix.toolchain }} @@ -73,5 +73,5 @@ jobs: - uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # master with: toolchain: stable - - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - run: cargo test From 33e975ade37646bae5a9c2bfececf0eb203bc9f9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:59:22 +0100 Subject: [PATCH 013/166] chore(deps): Bump dtolnay/rust-toolchain (#4) Bumps [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from 6d653acede28d24f02e3cd41383119e8b1b35921 to f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561. - [Release notes](https://github.com/dtolnay/rust-toolchain/releases) - [Commits](https://github.com/dtolnay/rust-toolchain/compare/6d653acede28d24f02e3cd41383119e8b1b35921...f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561) --- updated-dependencies: - dependency-name: dtolnay/rust-toolchain dependency-version: f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index db376dc7..a8ddb949 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # master + - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master with: toolchain: nightly components: rustfmt @@ -50,7 +50,7 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # master + - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master with: toolchain: ${{ matrix.toolchain }} components: clippy @@ -70,7 +70,7 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # master + - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master with: toolchain: stable - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 From 353f1dc77db5c16a7e3324632f76870d8ec17024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:06:20 +0100 Subject: [PATCH 014/166] fix: Improve address extraction from SMTP commands (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents extraction failing on SRS. Fixes: #9 Signed-off-by: Jagoda Ślązak --- filtermail/src/utils.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/filtermail/src/utils.rs b/filtermail/src/utils.rs index 526339e1..356270c7 100644 --- a/filtermail/src/utils.rs +++ b/filtermail/src/utils.rs @@ -7,20 +7,15 @@ use std::error::Error; /// /// Returns the first address if multiple are present. pub fn extract_address(input: &str) -> Option { - // TODO: at this point it's probably simpler to use regex ;p let input_lower = input.to_lowercase(); let mut trimmed = input_lower .trim_start_matches("mail from:") .trim_start_matches("rcpt to:"); + + let addr_end = trimmed.find('>').unwrap_or(trimmed.len() - 1); trimmed = trimmed - .split_once("=") - .map(|(address_raw, _)| { - address_raw - .rsplit_once(' ') - .map(|(addr, _)| addr) - .unwrap_or(address_raw) - .trim() - }) + .split_at_checked(addr_end + 1) + .map(|(address_raw, _)| address_raw) .unwrap_or(trimmed); mailparse::addrparse(trimmed) @@ -63,6 +58,8 @@ mod tests { #[rstest] #[case("MAIL FROM:", Some("t1@example.org".to_string()))] #[case("MAIL FROM: SOMETHING=SOMETHING OTHER=OTHER", Some("t2@example.org".to_string()))] + #[case("MAIL FROM: abc=def", Some("srs1=hhh=example.com==hhh=tt=example.org=alice@example.net".to_string()))] + #[case("MAIL FROM: abc=def", Some("abc+alice@example.net".to_string()))] #[case("RCPT TO:", Some("t3@example.org".to_string()))] #[case("mail from:", Some("t4@example.org".to_string()))] #[case("Foo Bar ", Some("t5@example.org".to_string()))] From f2f7767b7104b6ff0fbf9738e385b21bcd7f52cf Mon Sep 17 00:00:00 2001 From: l Date: Wed, 21 Jan 2026 13:25:40 +0000 Subject: [PATCH 015/166] refactor: get rid of indexing and slicing in check_armored_payload() (#15) Indexing can potentially panic, without indexing it is easier to make sure there will be no crash. --- filtermail/src/message.rs | 2 +- filtermail/src/openpgp.rs | 30 ++++++++++++------------------ 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/filtermail/src/message.rs b/filtermail/src/message.rs index 234d037c..b827a682 100644 --- a/filtermail/src/message.rs +++ b/filtermail/src/message.rs @@ -116,7 +116,7 @@ pub fn check_encrypted(mail: &mailparse::ParsedMail, outgoing: bool) -> bool { return false; } }; - if !check_armored_payload(payload, outgoing) { + if !check_armored_payload(&payload, outgoing) { log::debug!("check_encrypted: armored payload check failed"); return false; } diff --git a/filtermail/src/openpgp.rs b/filtermail/src/openpgp.rs index 582037b6..e221c8fc 100644 --- a/filtermail/src/openpgp.rs +++ b/filtermail/src/openpgp.rs @@ -96,23 +96,19 @@ fn check_openpgp_payload(payload: &[u8]) -> Result { /// /// Returns `true` if the `payload` is a valid PGP message, /// `outgoing` informs whether the message is outgoing or incoming -pub fn check_armored_payload(mut payload: String, outgoing: bool) -> bool { +pub fn check_armored_payload(payload: &str, outgoing: bool) -> bool { const PREFIX: &str = "-----BEGIN PGP MESSAGE-----\r\n"; - if !payload.starts_with(PREFIX) { + let Some(payload) = payload.strip_prefix(PREFIX) else { log::debug!("check_armored_payload: Did not find PGP MESSAGE prefix"); return false; - } - payload = payload[PREFIX.len()..].to_string(); + }; - while payload.ends_with("\r\n") { - payload.truncate(payload.len() - 2); - } + let payload = payload.trim_end_matches("\r\n"); const SUFFIX: &str = "-----END PGP MESSAGE-----"; - if !payload.ends_with(SUFFIX) { + let Some(mut payload) = payload.strip_suffix(SUFFIX) else { log::debug!("check_armored_payload: Did not find PGP MESSAGE suffix"); return false; - } - payload.truncate(payload.len() - SUFFIX.len()); + }; const VERSION_COMMENT: &str = "Version: "; if payload.starts_with(VERSION_COMMENT) { @@ -123,20 +119,18 @@ pub fn check_armored_payload(mut payload: String, outgoing: bool) -> bool { } // Remove comments from incoming messages if let Some((_, right)) = payload.split_once("\r\n") { - payload = right.to_string(); + payload = right; } } - while payload.starts_with("\r\n") { - payload = payload[2..].to_string(); - } + let mut payload = payload.trim_start_matches("\r\n"); // Remove CRC24. if let Some((left, _)) = payload.rsplit_once('=') { - payload = left.to_string(); + payload = left; } - payload = payload.replace(['\r', '\n'], ""); + let payload = payload.replace(['\r', '\n'], ""); let payload = match BASE64_STANDARD.decode(payload.as_bytes()) { Ok(v) => v, Err(_) => { @@ -260,10 +254,10 @@ Definitely not base64 encoded PGP message content. fn test_check_armored_payload(#[case] pgp_message: &str, #[case] expected: (bool, bool)) { let (expected_outgoing, expected_incoming) = expected; - let result = check_armored_payload(pgp_message.replace('\n', "\r\n").to_string(), true); + let result = check_armored_payload(&pgp_message.replace('\n', "\r\n"), true); assert_eq!(result, expected_outgoing); - let result = check_armored_payload(pgp_message.replace('\n', "\r\n").to_string(), false); + let result = check_armored_payload(&pgp_message.replace('\n', "\r\n"), false); assert_eq!(result, expected_incoming); } } From c010d999f5fc87d1dff19e3b259a8ce2a2166b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:26:20 +0100 Subject: [PATCH 016/166] fix: Correct a typo in SMTP answer (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/src/smtp_server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index 06e1e9af..ff59f0f6 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -117,7 +117,7 @@ where } else { log::debug!("Invalid MAIL FROM command. Can't extract address."); writer - .write_all(b"500 Invalid addreess in MAIL FROM\r\n") + .write_all(b"500 Invalid address in MAIL FROM\r\n") .await?; } } else if cmd.to_uppercase().starts_with("RCPT TO:") { From 41e9e9f6d0dff65f8cd326b0f48116bc83ed8f11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:26:37 +0100 Subject: [PATCH 017/166] chore(dist): Configure cargo-dist (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/Cargo.toml | 5 +++++ filtermail/dist-workspace.toml | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 filtermail/dist-workspace.toml diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 26194a67..feb6daa2 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -26,3 +26,8 @@ env_logger = "0.11.8" [dev-dependencies] rstest = "0.26.1" testresult = "0.4.1" + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/filtermail/dist-workspace.toml b/filtermail/dist-workspace.toml new file mode 100644 index 00000000..409b3761 --- /dev/null +++ b/filtermail/dist-workspace.toml @@ -0,0 +1,11 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.30.3" +# The installers to generate for each app +installers = [] +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"] From 2b4205a60af8c747b85bce7c0dbd99f3435e38e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:27:21 +0100 Subject: [PATCH 018/166] fix(config): Set default values for internal SMTP ports and max message size (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: #7 Signed-off-by: Jagoda Ślązak --- filtermail/src/config.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/filtermail/src/config.rs b/filtermail/src/config.rs index 8465044a..24224ed2 100644 --- a/filtermail/src/config.rs +++ b/filtermail/src/config.rs @@ -6,10 +6,15 @@ use std::path::{Path, PathBuf}; /// Chatmail configuration subset used by filtermail. #[derive(Debug, Clone, Deserialize)] pub struct Config { + #[serde(default = "Config::default_filtermail_smtp_port")] pub filtermail_smtp_port: u16, + #[serde(default = "Config::default_filtermail_smtp_port_incoming")] pub filtermail_smtp_port_incoming: u16, + #[serde(default = "Config::default_postfix_reinject_port")] pub postfix_reinject_port: u16, + #[serde(default = "Config::default_postfix_reinject_port_incoming")] pub postfix_reinject_port_incoming: u16, + #[serde(default = "Config::default_max_message_size")] pub max_message_size: usize, pub max_user_send_per_minute: usize, #[serde(default, deserialize_with = "deserialize_sequence")] @@ -50,7 +55,7 @@ impl Config { Ok(wrapped_config.params) } - /// Get the mailboxes directory, using defaulting to `/home/vmail/mail/` if not set. + /// Get the mailboxes directory, defaulting to `/home/vmail/mail/` if not set. fn mailboxes_dir(&self) -> PathBuf { match &self.mailboxes_dir { Some(dir) => dir.clone(), @@ -70,4 +75,22 @@ impl Config { !enforce_e2ee.exists() } + + // Following are needed since serde does not support default literals. + + const fn default_filtermail_smtp_port() -> u16 { + 10080 + } + const fn default_filtermail_smtp_port_incoming() -> u16 { + 10081 + } + const fn default_postfix_reinject_port() -> u16 { + 10025 + } + const fn default_postfix_reinject_port_incoming() -> u16 { + 10026 + } + const fn default_max_message_size() -> usize { + 31457280 + } } From aab39be662b0f6be854f026b678e373ee2c79c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:35:52 +0100 Subject: [PATCH 019/166] feat: Improve logging (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improves logs and reduces verbosity. Fixes: #8 Signed-off-by: Jagoda Ślązak --- filtermail/src/inbound.rs | 12 ++++++------ filtermail/src/main.rs | 4 ++-- filtermail/src/openpgp.rs | 10 ++-------- filtermail/src/outbound.rs | 16 ++++++++-------- filtermail/src/smtp_server.rs | 14 +++++++------- 5 files changed, 25 insertions(+), 31 deletions(-) diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 4eabbf8f..2fc47b59 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -32,7 +32,7 @@ impl SmtpHandler for IncomingBeforeQueueHandler { } fn check_data(&self, envelope: &Envelope) -> Result<(), String> { - log::info!("Processing DATA message from {}", envelope.mail_from); + log::debug!("Processing DATA message from {}", envelope.mail_from); let message = match parse_mail(&envelope.data) { Ok(m) => m, @@ -40,16 +40,16 @@ impl SmtpHandler for IncomingBeforeQueueHandler { }; let mail_encrypted = check_encrypted(&message, false); - log::debug!("mail_encrypted: {}", mail_encrypted); + log::debug!("mail_encrypted: {mail_encrypted}"); log::debug!("is_securejoin: {}", is_securejoin(&message)); // Allow encrypted or securejoin messages if mail_encrypted || is_securejoin(&message) { - log::info!("Incoming: Filtering encrypted mail."); + log::debug!("Incoming: Filtering encrypted mail."); return Ok(()); } - log::info!("Incoming: Filtering unencrypted mail."); + log::debug!("Incoming: Filtering unencrypted mail."); // Allow cleartext mailer-daemon messages if let Some(auto_submitted) = message.headers.get_first_value("Auto-Submitted") @@ -72,7 +72,7 @@ impl SmtpHandler for IncomingBeforeQueueHandler { for recipient in &envelope.rcpt_to { if !self.config.is_cleartext_ok(recipient) { - log::info!("Rejected unencrypted mail."); + log::warn!("Rejected unencrypted email from: {}", envelope.mail_from); return Err(ENCRYPTION_NEEDED_523.to_string()); } } @@ -81,7 +81,7 @@ impl SmtpHandler for IncomingBeforeQueueHandler { } async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { - log::info!("Re-injecting the mail that passed checks"); + log::debug!("Re-injecting the mail that passed checks"); let mailer = AsyncSmtpTransport::::builder_dangerous("localhost") .port(self.config.postfix_reinject_port_incoming) diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index 129af418..74b356ac 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -55,7 +55,7 @@ async fn main() { let handler = Arc::new(OutgoingBeforeQueueHandler::new(config.clone())); let addr = format!("127.0.0.1:{}", config.filtermail_smtp_port); let max_size = config.max_message_size; - log::debug!("Outgoing SMTP server listening on {}", addr); + log::debug!("Outgoing SMTP server listening on {addr}"); if let Err(e) = run_smtp_server(&addr, handler, max_size).await { eprintln!("Server error: {}", e); @@ -65,7 +65,7 @@ async fn main() { let handler = Arc::new(IncomingBeforeQueueHandler::new(config.clone())); let addr = format!("127.0.0.1:{}", config.filtermail_smtp_port_incoming); let max_size = config.max_message_size; - log::debug!("Incoming SMTP server listening on {}", addr); + log::debug!("Incoming SMTP server listening on {addr}"); if let Err(e) = run_smtp_server(&addr, handler, max_size).await { eprintln!("Server error: {}", e); diff --git a/filtermail/src/openpgp.rs b/filtermail/src/openpgp.rs index e221c8fc..04dd85b5 100644 --- a/filtermail/src/openpgp.rs +++ b/filtermail/src/openpgp.rs @@ -71,20 +71,14 @@ fn check_openpgp_payload(payload: &[u8]) -> Result { // Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD) // // This is the only place where this function may return `True`. - log::debug!( - "check_openpgp_payload: i={i} packat_type_id={}", - packet_type_id - ); + log::debug!("check_openpgp_payload: i={i} packat_type_id={packet_type_id}"); return Ok(packet_type_id == 18); } else if ![1, 3].contains(&packet_type_id) { // All packets except the last one must be either // Public-Key Encrypted Session Key Packet (PKESK) // or // Symmetric-Key Encrypted Session Key Packet (SKESK) - log::debug!( - "check_openpgp_payload: i={i} packet_type_id={}", - packet_type_id - ); + log::debug!("check_openpgp_payload: i={i} packet_type_id={packet_type_id}"); return Ok(false); } } diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index dee3e60a..85452709 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -30,7 +30,7 @@ impl OutgoingBeforeQueueHandler { #[async_trait] impl SmtpHandler for OutgoingBeforeQueueHandler { fn handle_mail(&self, address: &str) -> Result<(), String> { - log::info!("handle_MAIL from {}", address); + log::debug!("handle_MAIL from {address}"); let parts: Vec<&str> = address.split('@').collect(); if parts.len() != 2 { @@ -40,15 +40,15 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { let max_sent = self.config.max_user_send_per_minute; let mut limiter = self.send_rate_limiter.lock().unwrap(); if !limiter.is_sending_allowed(address, max_sent) { - log::debug!("Rate limit exceeded for {}", address); - return Err(format!("450 4.7.1: Too much mail from {}", address)); + log::debug!("Rate limit exceeded for {address}"); + return Err(format!("450 4.7.1: Too much mail from {address}")); } Ok(()) } fn check_data(&self, envelope: &Envelope) -> Result<(), String> { - log::info!("Processing DATA message from {}", envelope.mail_from); + log::debug!("Processing DATA message from {}", envelope.mail_from); let message = match parse_mail(&envelope.data) { Ok(m) => m, @@ -76,11 +76,11 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { // Allow encrypted or securejoin messages if mail_encrypted || is_securejoin(&message) { - log::info!("Outgoing: Filtering encrypted mail."); + log::debug!("Outgoing: Filtering encrypted mail."); return Ok(()); } - log::info!("Outgoing: Filtering unencrypted mail."); + log::debug!("Outgoing: Filtering unencrypted mail."); // Allow passthrough senders if self @@ -104,7 +104,7 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { for recipient in &envelope.rcpt_to { if !recipient_matches_passthrough(recipient, &self.config.passthrough_recipients) { - log::info!("Rejected unencrypted mail."); + log::warn!("Rejected unencrypted mail from: {}", envelope.mail_from); return Err(ENCRYPTION_NEEDED_523.to_string()); } } @@ -113,7 +113,7 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { } async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { - log::info!("Re-injecting the mail that passed checks"); + log::debug!("Re-injecting the mail that passed checks"); let mailer = AsyncSmtpTransport::::builder_dangerous("localhost") .port(self.config.postfix_reinject_port) diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index ff59f0f6..431b846f 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -28,10 +28,10 @@ pub trait SmtpHandler: Send + Sync { /// Handles the DATA command. async fn handle_data(&self, envelope: &Envelope) -> Result { - log::info!("handle_DATA before-queue"); + log::debug!("handle_DATA before-queue"); self.check_data(envelope)?; self.reinject_mail(envelope).await.map_err(|e| { - log::warn!("Failed to reinject mail: {}", e); + log::warn!("Failed to reinject mail: {e}"); e })?; Ok("250 OK".to_string()) @@ -56,7 +56,7 @@ where let handler = handler.clone(); tokio::spawn(async move { if let Err(e) = handle_connection(socket, handler, max_size).await { - log::error!("Error handling connection: {}", e); + log::error!("Error handling connection: {e}"); } }); } @@ -98,7 +98,7 @@ where break 'connection; }; - log::debug!("Received: {}", cmd); + log::debug!("Received: {cmd}"); if cmd.to_uppercase().starts_with("HELO") || cmd.to_uppercase().starts_with("EHLO") { writer.write_all(b"250 OK\r\n").await?; @@ -115,7 +115,7 @@ where } } } else { - log::debug!("Invalid MAIL FROM command. Can't extract address."); + log::warn!("Invalid MAIL FROM command. Can't extract address. Received: {cmd}"); writer .write_all(b"500 Invalid address in MAIL FROM\r\n") .await?; @@ -159,13 +159,13 @@ where // Process the message match handler.handle_data(&envelope).await { Ok(response) => { - log::debug!("Sent: {}", response); + log::debug!("Sent: {response}"); writer .write_all(format!("{}\r\n", response).as_bytes()) .await?; } Err(e) => { - log::debug!("Sent: {}", e); + log::debug!("Sent: {e}"); writer.write_all(format!("{}\r\n", e).as_bytes()).await?; } } From 9eb86b23cef6b9d3d8b173f299cb47f7cee01196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:28:24 +0100 Subject: [PATCH 020/166] refactor: Apply more lints (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets lint rules and applies required changes. Fixes: #16 Signed-off-by: Jagoda Ślązak --- filtermail/src/main.rs | 39 +++++++++++++++++++++++++++++++++++--- filtermail/src/message.rs | 4 +++- filtermail/src/outbound.rs | 5 ++++- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index 74b356ac..b81dbb3d 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -1,3 +1,29 @@ +#![doc = include_str!("../README.md")] +#![forbid(unsafe_code)] +#![warn( + unused, + clippy::correctness, + missing_debug_implementations, + missing_docs, + clippy::all, + clippy::wildcard_imports, + clippy::needless_borrow, + clippy::cast_lossless, + clippy::unused_async, + clippy::explicit_iter_loop, + clippy::explicit_into_iter_loop, + clippy::cloned_instead_of_copied +)] +#![cfg_attr(not(test), forbid(clippy::indexing_slicing))] +#![cfg_attr(not(test), forbid(clippy::string_slice))] +#![allow( + clippy::match_bool, + clippy::mixed_read_write_in_expression, + clippy::bool_assert_comparison, + clippy::manual_split_once, + clippy::format_push_string, + clippy::bool_to_int_with_if +)] mod config; pub(crate) mod error; pub(crate) mod inbound; @@ -30,13 +56,20 @@ async fn main() { let args: Vec = env::args().collect(); if args.len() != 3 { - eprintln!("Usage: {} ", args[0]); + eprintln!( + "Usage: {} ", + args.first().unwrap_or(&"filtermail".to_string()) + ); eprintln!(" mode: incoming or outgoing"); process::exit(1); } - let config_path = &args[1]; - let mode = &args[2]; + let Some(config_path) = args.get(1) else { + unreachable!("args length checked above") + }; + let Some(mode) = args.get(2) else { + unreachable!("args length checked above") + }; if mode != "incoming" && mode != "outgoing" { eprintln!("Error: mode must be 'incoming' or 'outgoing'"); diff --git a/filtermail/src/message.rs b/filtermail/src/message.rs index b827a682..b9ce9fcf 100644 --- a/filtermail/src/message.rs +++ b/filtermail/src/message.rs @@ -25,7 +25,9 @@ pub fn is_securejoin(mail: &mailparse::ParsedMail) -> bool { return false; } - let part = &mail.subparts[0]; + let Some(part) = &mail.subparts.first() else { + return false; + }; // Part must not be multipart if !part.subparts.is_empty() { diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 85452709..663c3891 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -92,7 +92,10 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { } // Allow self-sent Autocrypt Setup Message - if envelope.rcpt_to.len() == 1 && envelope.rcpt_to[0] == envelope.mail_from { + if envelope.rcpt_to.len() == 1 + && let Some(rcpt_to) = envelope.rcpt_to.first() + && *rcpt_to == envelope.mail_from + { let subject = message .headers .get_first_value("Subject") From e214a74252f998e820b6d9084026b78c0ac94f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Wed, 21 Jan 2026 17:17:13 +0100 Subject: [PATCH 021/166] chore: Configure git-cliff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/cliff.toml | 94 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 filtermail/cliff.toml diff --git a/filtermail/cliff.toml b/filtermail/cliff.toml new file mode 100644 index 00000000..f8786675 --- /dev/null +++ b/filtermail/cliff.toml @@ -0,0 +1,94 @@ +# git-cliff ~ configuration file +# https://git-cliff.org/docs/configuration + + +[changelog] +# A Tera template to be rendered for each release in the changelog. +# See https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %} +""" +# Remove leading and trailing whitespaces from the changelog's body. +trim = true +# Render body even when there are no releases to process. +render_always = true +# An array of regex based postprocessors to modify the changelog. +postprocessors = [ + # Replace the placeholder with a URL. + #{ pattern = '', replace = "https://github.com/orhun/git-cliff" }, +] +# render body even when there are no releases to process +# render_always = true +# output file path +# output = "test.md" + +[git] +# Parse commits according to the conventional commits specification. +# See https://www.conventionalcommits.org +conventional_commits = true +# Exclude commits that do not match the conventional commits specification. +filter_unconventional = true +# Require all commits to be conventional. +# Takes precedence over filter_unconventional. +require_conventional = false +# Split commits on newlines, treating each line as an individual commit. +split_commits = false +# An array of regex based parsers to modify commit messages prior to further processing. +commit_preprocessors = [ + # Replace issue numbers with link templates to be updated in `changelog.postprocessors`. + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit message using https://github.com/crate-ci/typos. + # If the spelling is incorrect, it will be fixed automatically. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# Prevent commits that are breaking from being excluded by commit parsers. +protect_breaking_commits = false +# An array of regex based parsers for extracting data from the commit message. +# Assigns commits to groups. +# Optionally sets the commit's scope and can decide to exclude commits from further processing. +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^docs", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore|^ci", group = "Miscellaneous Tasks" }, + { body = ".*security", group = "Security" }, + { message = "^revert", group = "Revert" }, + { message = ".*", group = "Other" }, +] +# Exclude commits that are not matched by any commit parser. +filter_commits = false +# Fail on a commit that is not matched by any commit parser. +fail_on_unmatched_commit = false +# An array of link parsers for extracting external references, and turning them into URLs, using regex. +link_parsers = [] +# Include only the tags that belong to the current branch. +use_branch_tags = false +# Order releases topologically instead of chronologically. +topo_order = false +# Order commits topologically instead of chronologically. +topo_order_commits = true +# Order of commits in each group/release within the changelog. +# Allowed values: newest, oldest +sort_commits = "oldest" +# Process submodules commits +recurse_submodules = false From 06b7b0ca6ddc100986c4d6413a8b78bcfd978165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Wed, 21 Jan 2026 17:19:05 +0100 Subject: [PATCH 022/166] chore(release): prepare for 0.1.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 filtermail/CHANGELOG.md diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md new file mode 100644 index 00000000..3a4a450c --- /dev/null +++ b/filtermail/CHANGELOG.md @@ -0,0 +1,39 @@ +## 0.1.1 - 2026-01-21 + +### Bug Fixes + +- Improve address extraction from SMTP commands (#14) +- Correct a typo in SMTP answer (#11) +- *(config)* Set default values for internal SMTP ports and max message size (#12) + +### Features + +- Improve logging (#13) + +### Miscellaneous Tasks + +- *(dist)* Configure cargo-dist (#10) +- Configure git-cliff + +### Refactor + +- Get rid of indexing and slicing in check_armored_payload() (#15) +- Apply more lints (#17) +## 0.1.0 - 2026-01-19 + +### Documentation + +- *(readme)* Add README.md +- *(license)* Add LICENSE + +### Features + +- Initial implementation + +### Miscellaneous Tasks + +- Init repository +- *(dependabot)* Setup dependabot +- *(ci)* Setup CI +- *(dependabot)* Add github-actions to dependabot +- *(cargo)* Add metadata diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 5c91650b..c94f5fe1 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -232,7 +232,7 @@ dependencies = [ [[package]] name = "filtermail" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index feb6daa2..12bb72ee 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.1.0" +version = "0.1.1" edition = "2024" license = "MIT" readme = "README.md" From b5a56a6a4e9a147e919e25b275374511fad7e786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:49:26 +0100 Subject: [PATCH 023/166] perf: Use governor for rate limiting (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: #19 Signed-off-by: Jagoda Ślązak --- filtermail/Cargo.lock | 283 +++++++++++++++++++++++++++++++++ filtermail/Cargo.toml | 1 + filtermail/src/config.rs | 3 +- filtermail/src/main.rs | 1 - filtermail/src/outbound.rs | 29 +++- filtermail/src/rate_limiter.rs | 35 ---- 6 files changed, 307 insertions(+), 45 deletions(-) delete mode 100644 filtermail/src/rate_limiter.rs diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index c94f5fe1..3db62d88 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -111,6 +111,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + [[package]] name = "bytes" version = "1.11.0" @@ -159,6 +165,26 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -237,6 +263,7 @@ dependencies = [ "async-trait", "base64", "env_logger", + "governor", "lettre", "log", "mailparse", @@ -254,6 +281,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -286,6 +319,12 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + [[package]] name = "futures-task" version = "0.3.31" @@ -307,6 +346,7 @@ dependencies = [ "futures-core", "futures-io", "futures-macro", + "futures-sink", "futures-task", "memchr", "pin-project-lite", @@ -314,12 +354,49 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -335,6 +412,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "icu_collections" @@ -478,6 +560,16 @@ dependencies = [ "syn", ] +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lettre" version = "0.11.19" @@ -562,6 +654,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "object" version = "0.32.2" @@ -648,6 +746,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -676,6 +783,21 @@ dependencies = [ "cc", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quote" version = "1.0.43" @@ -691,6 +813,50 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -773,6 +939,12 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "scopeguard" version = "1.2.0" @@ -863,6 +1035,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1040,6 +1221,102 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -1211,6 +1488,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "writeable" version = "0.6.2" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 12bb72ee..a4ed8596 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -22,6 +22,7 @@ lettre = { version = "0.11.19", default-features = false, features = [ ] } log = "0.4.29" env_logger = "0.11.8" +governor = "0.10.4" [dev-dependencies] rstest = "0.26.1" diff --git a/filtermail/src/config.rs b/filtermail/src/config.rs index 24224ed2..04ef6997 100644 --- a/filtermail/src/config.rs +++ b/filtermail/src/config.rs @@ -1,6 +1,7 @@ //! Configuration file handling for filtermail. use serde::{Deserialize, Deserializer}; +use std::num::NonZeroU32; use std::path::{Path, PathBuf}; /// Chatmail configuration subset used by filtermail. @@ -16,7 +17,7 @@ pub struct Config { pub postfix_reinject_port_incoming: u16, #[serde(default = "Config::default_max_message_size")] pub max_message_size: usize, - pub max_user_send_per_minute: usize, + pub max_user_send_per_minute: NonZeroU32, #[serde(default, deserialize_with = "deserialize_sequence")] pub passthrough_senders: Vec, #[serde(default, deserialize_with = "deserialize_sequence")] diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index b81dbb3d..12b2048d 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -30,7 +30,6 @@ pub(crate) mod inbound; pub(crate) mod message; pub(crate) mod openpgp; pub(crate) mod outbound; -pub(crate) mod rate_limiter; pub(crate) mod smtp_server; pub(crate) mod utils; diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 663c3891..2d67c33d 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -3,11 +3,11 @@ use crate::ENCRYPTION_NEEDED_523; use crate::config::Config; use crate::message::{check_encrypted, is_securejoin, recipient_matches_passthrough}; -use crate::rate_limiter::SendRateLimiter; pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; use crate::utils::{extract_address, format_smtp_error}; use async_trait::async_trait; +use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; use mailparse::{MailHeaderMap, parse_mail}; use std::sync::{Arc, Mutex}; @@ -15,14 +15,15 @@ use std::sync::{Arc, Mutex}; /// Handler for outgoing SMTP messages. pub struct OutgoingBeforeQueueHandler { config: Arc, - send_rate_limiter: Arc>, + send_rate_limiter: Arc>>, } impl OutgoingBeforeQueueHandler { pub fn new(config: Config) -> Self { + let quota = Quota::per_minute(config.max_user_send_per_minute); Self { config: Arc::new(config), - send_rate_limiter: Arc::new(Mutex::new(SendRateLimiter::default())), + send_rate_limiter: Arc::new(Mutex::new(RateLimiter::keyed(quota))), } } } @@ -37,13 +38,25 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { return Err(format!("500 Invalid from address <{}>", address)); } - let max_sent = self.config.max_user_send_per_minute; - let mut limiter = self.send_rate_limiter.lock().unwrap(); - if !limiter.is_sending_allowed(address, max_sent) { - log::debug!("Rate limit exceeded for {address}"); - return Err(format!("450 4.7.1: Too much mail from {address}")); + let Ok(limiter) = self.send_rate_limiter.lock() else { + log::error!("send_rate_limiter lock panicked!"); + return Err("451 Temporary server error".to_string()); + }; + if let Err(e) = limiter.check_key(&address.to_string()) { + // " rate limited until: ..." + log::debug!("<{address}> {e}"); + return Err(format!("450 4.7.1: Too much mail from <{address}>, {e}")); } + // Cleanup + // + // This is only called after a successful check, + // so a spam of mails from the same user will not cause calling this repeatedly. + // In the future, in case of higher traffic this can be further optimized by e.g. calling it + // every N messages or in a separate task every N minutes. + // Time complexity is O(n) where n is the number of unique senders in the last minute. + limiter.retain_recent(); + Ok(()) } diff --git a/filtermail/src/rate_limiter.rs b/filtermail/src/rate_limiter.rs deleted file mode 100644 index 00bbb13a..00000000 --- a/filtermail/src/rate_limiter.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Module for rate limiting. - -use std::collections::HashMap; -use std::time::{Duration, SystemTime}; - -const ONE_MINUTE: Duration = Duration::from_secs(60); - -/// A rate limiter tracking send timestamps per address. -#[derive(Debug, Default)] -pub struct SendRateLimiter { - address_timestamps: HashMap>, -} - -impl SendRateLimiter { - pub fn is_sending_allowed(&mut self, mail_from: &str, max_send_per_minute: usize) -> bool { - self.address_timestamps.retain(|_, timestamps| { - timestamps - .last() - .map(|t| t.elapsed().unwrap_or_default() <= ONE_MINUTE) - .unwrap_or(false) - }); - - let last = self - .address_timestamps - .entry(mail_from.to_string()) - .or_default(); - last.retain(|&send_time| send_time.elapsed().unwrap_or_default() <= ONE_MINUTE); - if last.len() <= max_send_per_minute { - last.push(SystemTime::now()); - true - } else { - false - } - } -} From ca6ccd9acf2cc11120642b749f8d374c14c2c6e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:49:55 +0100 Subject: [PATCH 024/166] fix: Set logs required by grafana to INFO (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/src/inbound.rs | 4 ++-- filtermail/src/outbound.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 2fc47b59..9f1623fc 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -45,11 +45,11 @@ impl SmtpHandler for IncomingBeforeQueueHandler { // Allow encrypted or securejoin messages if mail_encrypted || is_securejoin(&message) { - log::debug!("Incoming: Filtering encrypted mail."); + log::info!("Incoming: Filtering encrypted mail."); return Ok(()); } - log::debug!("Incoming: Filtering unencrypted mail."); + log::info!("Incoming: Filtering unencrypted mail."); // Allow cleartext mailer-daemon messages if let Some(auto_submitted) = message.headers.get_first_value("Auto-Submitted") diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 2d67c33d..9535f379 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -89,11 +89,11 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { // Allow encrypted or securejoin messages if mail_encrypted || is_securejoin(&message) { - log::debug!("Outgoing: Filtering encrypted mail."); + log::info!("Outgoing: Filtering encrypted mail."); return Ok(()); } - log::debug!("Outgoing: Filtering unencrypted mail."); + log::info!("Outgoing: Filtering unencrypted mail."); // Allow passthrough senders if self From cab16fc8f1f6223c84ee0922b7f95f9405b815d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:14:28 +0100 Subject: [PATCH 025/166] fix: Make inbound/outbound log messages consistent (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/src/inbound.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 9f1623fc..def5d2cb 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -72,7 +72,7 @@ impl SmtpHandler for IncomingBeforeQueueHandler { for recipient in &envelope.rcpt_to { if !self.config.is_cleartext_ok(recipient) { - log::warn!("Rejected unencrypted email from: {}", envelope.mail_from); + log::warn!("Rejected unencrypted mail from: {}", envelope.mail_from); return Err(ENCRYPTION_NEEDED_523.to_string()); } } From 0990a2370094880e80421beafe1df9d0cdd16a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:24:33 +0100 Subject: [PATCH 026/166] chore(release): prepare for 0.1.2 (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 10 ++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index 3a4a450c..dd40d3ea 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,13 @@ +## 0.1.2 - 2026-01-22 + +### Bug Fixes + +- Set logs required by grafana to INFO (#21) +- Make inbound/outbound log messages consistent (#23) + +### Performance + +- Use governor for rate limiting (#20) ## 0.1.1 - 2026-01-21 ### Bug Fixes diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 3db62d88..fda5f2f2 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -258,7 +258,7 @@ dependencies = [ [[package]] name = "filtermail" -version = "0.1.1" +version = "0.1.2" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index a4ed8596..e44187da 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.1.1" +version = "0.1.2" edition = "2024" license = "MIT" readme = "README.md" From 3be4c4d5eed273f2fd7cdfbc3d38afa1b49a3f19 Mon Sep 17 00:00:00 2001 From: link2xt Date: Mon, 26 Jan 2026 13:20:15 +0000 Subject: [PATCH 027/166] perf: disable Nagle's algorithm and do own buffering on server connections I don't know if Postfix as the client disables delayed ACKs, but if delayed ACKs are enabled, Nagle's algorithm introduces unnecessary delays. --- filtermail/src/smtp_server.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index 431b846f..04446c97 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -3,7 +3,7 @@ use crate::utils::extract_address; use async_trait::async_trait; use std::sync::Arc; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; use tokio::net::{TcpListener, TcpStream}; /// Represents an SMTP envelope with sender, recipients, and raw message data. @@ -53,6 +53,10 @@ where loop { let (socket, _) = listener.accept().await?; + + // Disable Nagle's algorithm. + socket.set_nodelay(true)?; + let handler = handler.clone(); tokio::spawn(async move { if let Err(e) = handle_connection(socket, handler, max_size).await { @@ -71,11 +75,13 @@ async fn handle_connection( where H: SmtpHandler, { - let (reader, mut writer) = socket.into_split(); + let (reader, writer) = socket.into_split(); let mut reader = BufReader::new(reader); + let mut writer = BufWriter::new(writer); let mut line = String::new(); writer.write_all(b"220 filtermail SMTP\r\n").await?; + writer.flush().await?; let mut envelope = Envelope { mail_from: String::new(), @@ -102,15 +108,18 @@ where if cmd.to_uppercase().starts_with("HELO") || cmd.to_uppercase().starts_with("EHLO") { writer.write_all(b"250 OK\r\n").await?; + writer.flush().await?; } else if cmd.to_uppercase().starts_with("MAIL FROM:") { if let Some(from) = extract_address(cmd) { match handler.handle_mail(&from) { Ok(_) => { envelope.mail_from = from; writer.write_all(b"250 OK\r\n").await?; + writer.flush().await?; } Err(e) => { writer.write_all(format!("{}\r\n", e).as_bytes()).await?; + writer.flush().await?; break 'connection; } } @@ -119,16 +128,19 @@ where writer .write_all(b"500 Invalid address in MAIL FROM\r\n") .await?; + writer.flush().await?; } } else if cmd.to_uppercase().starts_with("RCPT TO:") { if let Some(to) = extract_address(cmd) { envelope.rcpt_to.push(to); writer.write_all(b"250 OK\r\n").await?; + writer.flush().await?; } } else if cmd.to_uppercase().starts_with("DATA") { writer .write_all(b"354 End data with .\r\n") .await?; + writer.flush().await?; let mut data = Vec::new(); let mut data_line = String::new(); 'data_read: loop { @@ -150,6 +162,7 @@ where writer .write_all(b"552 Message exceeds maximum size\r\n") .await?; + writer.flush().await?; break 'connection; } } @@ -163,10 +176,12 @@ where writer .write_all(format!("{}\r\n", response).as_bytes()) .await?; + writer.flush().await?; } Err(e) => { log::debug!("Sent: {e}"); writer.write_all(format!("{}\r\n", e).as_bytes()).await?; + writer.flush().await?; } } @@ -177,6 +192,7 @@ where }; } else if cmd.to_uppercase().starts_with("QUIT") { writer.write_all(b"221 OK\r\n").await?; + writer.flush().await?; break 'connection; } else if cmd.to_uppercase().starts_with("RSET") { envelope = Envelope { @@ -185,10 +201,13 @@ where data: Vec::new(), }; writer.write_all(b"250 OK\r\n").await?; + writer.flush().await?; } else if cmd.to_uppercase().starts_with("NOOP") { writer.write_all(b"250 OK\r\n").await?; + writer.flush().await?; } else { writer.write_all(b"500 Command not recognized\r\n").await?; + writer.flush().await?; } } From aa5999cc2579753f427dbd7691f7a4b698993fda Mon Sep 17 00:00:00 2001 From: link2xt Date: Mon, 26 Jan 2026 14:27:29 +0000 Subject: [PATCH 028/166] refactor: remove Mutex around rate limiter Rate limiter handles all the necessary locking internally. --- filtermail/src/outbound.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 9535f379..907e9caf 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -10,12 +10,12 @@ use async_trait::async_trait; use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; use mailparse::{MailHeaderMap, parse_mail}; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; /// Handler for outgoing SMTP messages. pub struct OutgoingBeforeQueueHandler { config: Arc, - send_rate_limiter: Arc>>, + send_rate_limiter: DefaultKeyedRateLimiter, } impl OutgoingBeforeQueueHandler { @@ -23,7 +23,7 @@ impl OutgoingBeforeQueueHandler { let quota = Quota::per_minute(config.max_user_send_per_minute); Self { config: Arc::new(config), - send_rate_limiter: Arc::new(Mutex::new(RateLimiter::keyed(quota))), + send_rate_limiter: RateLimiter::keyed(quota), } } } @@ -38,11 +38,7 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { return Err(format!("500 Invalid from address <{}>", address)); } - let Ok(limiter) = self.send_rate_limiter.lock() else { - log::error!("send_rate_limiter lock panicked!"); - return Err("451 Temporary server error".to_string()); - }; - if let Err(e) = limiter.check_key(&address.to_string()) { + if let Err(e) = self.send_rate_limiter.check_key(&address.to_string()) { // " rate limited until: ..." log::debug!("<{address}> {e}"); return Err(format!("450 4.7.1: Too much mail from <{address}>, {e}")); @@ -55,7 +51,7 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { // In the future, in case of higher traffic this can be further optimized by e.g. calling it // every N messages or in a separate task every N minutes. // Time complexity is O(n) where n is the number of unique senders in the last minute. - limiter.retain_recent(); + self.send_rate_limiter.retain_recent(); Ok(()) } From c0104152e84f9ee7fe7db4757850c1f9c2c49607 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:06:35 +0100 Subject: [PATCH 029/166] chore(deps): Bump actions/checkout from 6.0.1 to 6.0.2 (#25) Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.1 to 6.0.2. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/8e8c483db84b4bee98b60c0593521ed34d9990e8...de0fac2e4500dabe0009e67214ff5f5447ce83dd) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index a8ddb949..42148e27 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: name: Check Formatting runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master @@ -33,7 +33,7 @@ jobs: name: Check Unused Dependencies runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: bnjbvr/cargo-machete@7959c845782fed02ee69303126d4a12d64f1db18 # v0.9.1 @@ -47,7 +47,7 @@ jobs: toolchain: ["stable", "beta"] continue-on-error: ${{ matrix.toolchain == 'beta' }} steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master @@ -67,7 +67,7 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macos-latest] steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master From 9a8e4fe65ccbce3fdaf9587cd32937036c7fc000 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:07:52 +0100 Subject: [PATCH 030/166] chore(deps): Bump taiki-e/install-action from 2.66.7 to 2.67.6 (#24) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.66.7 to 2.67.6. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/542cebaaed782771e619bd5609d97659d109c492...8444b4f2165ec93147f3ee41e06495653063ad11) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.67.6 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 42148e27..c70e6e28 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - - uses: taiki-e/install-action@542cebaaed782771e619bd5609d97659d109c492 # v2 + - uses: taiki-e/install-action@8444b4f2165ec93147f3ee41e06495653063ad11 # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check From 23231d462c74f9520433f93c22d3418774ebd2d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:47:48 +0100 Subject: [PATCH 031/166] feat: Configurable rate limiter max burst size (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/src/config.rs | 9 +++++++++ filtermail/src/outbound.rs | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/filtermail/src/config.rs b/filtermail/src/config.rs index 04ef6997..96f7f08e 100644 --- a/filtermail/src/config.rs +++ b/filtermail/src/config.rs @@ -17,7 +17,10 @@ pub struct Config { pub postfix_reinject_port_incoming: u16, #[serde(default = "Config::default_max_message_size")] pub max_message_size: usize, + #[serde(default = "Config::default_max_user_send_per_minute")] pub max_user_send_per_minute: NonZeroU32, + #[serde(default = "Config::default_max_user_send_burst_size")] + pub max_user_send_burst_size: NonZeroU32, #[serde(default, deserialize_with = "deserialize_sequence")] pub passthrough_senders: Vec, #[serde(default, deserialize_with = "deserialize_sequence")] @@ -94,4 +97,10 @@ impl Config { const fn default_max_message_size() -> usize { 31457280 } + const fn default_max_user_send_per_minute() -> NonZeroU32 { + NonZeroU32::new(60).expect("60 != 0") + } + const fn default_max_user_send_burst_size() -> NonZeroU32 { + NonZeroU32::new(10).expect("10 != 0") + } } diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 907e9caf..37858c34 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -20,7 +20,8 @@ pub struct OutgoingBeforeQueueHandler { impl OutgoingBeforeQueueHandler { pub fn new(config: Config) -> Self { - let quota = Quota::per_minute(config.max_user_send_per_minute); + let quota = Quota::per_minute(config.max_user_send_per_minute) + .allow_burst(config.max_user_send_burst_size); Self { config: Arc::new(config), send_rate_limiter: RateLimiter::keyed(quota), From 95fd106e431af72a3417176ae5922689fbafbd83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 28 Jan 2026 18:22:53 +0100 Subject: [PATCH 032/166] chore(release): prepare for 0.2.0 (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 13 +++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index dd40d3ea..8c2b524e 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,16 @@ +## 0.2.0 - 2026-01-28 + +### Features + +- Configurable rate limiter max burst size (#28) + +### Performance + +- Disable Nagle's algorithm and do own buffering on server connections + +### Refactor + +- Remove Mutex around rate limiter ## 0.1.2 - 2026-01-22 ### Bug Fixes diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index fda5f2f2..c5739c96 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -258,7 +258,7 @@ dependencies = [ [[package]] name = "filtermail" -version = "0.1.2" +version = "0.2.0" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index e44187da..af268a99 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.1.2" +version = "0.2.0" edition = "2024" license = "MIT" readme = "README.md" From 7be2004051b0ccc074a477f559ebca180543c6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:06:04 +0100 Subject: [PATCH 033/166] chore(dist): Switch to musl targets (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensures filtermail binaries are 100% statically linked. Signed-off-by: Jagoda Ślązak --- filtermail/dist-workspace.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/dist-workspace.toml b/filtermail/dist-workspace.toml index 409b3761..d3e27f44 100644 --- a/filtermail/dist-workspace.toml +++ b/filtermail/dist-workspace.toml @@ -8,4 +8,4 @@ cargo-dist-version = "0.30.3" # The installers to generate for each app installers = [] # Target platforms to build apps for (Rust target-triple syntax) -targets = ["aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"] +targets = ["aarch64-unknown-linux-musl", "x86_64-unknown-linux-musl"] From 47f16360a4871e9423e38827e0e11e945a137b36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:05:48 +0100 Subject: [PATCH 034/166] chore(deps): Bump taiki-e/install-action from 2.67.6 to 2.67.16 (#34) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.67.6 to 2.67.16. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/8444b4f2165ec93147f3ee41e06495653063ad11...a362280ea100b5bb920cf9040c038ce9a50c7943) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.67.16 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index c70e6e28..fd5531e1 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - - uses: taiki-e/install-action@8444b4f2165ec93147f3ee41e06495653063ad11 # v2 + - uses: taiki-e/install-action@a362280ea100b5bb920cf9040c038ce9a50c7943 # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check From 8d18b7368172a9f18d4a0793ab717cdb05f2e4f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:42:06 +0100 Subject: [PATCH 035/166] refactor: Remove unnecessary Arc (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Arc` around handler configs are not needed, as they are not shared. Signed-off-by: Jagoda Ślązak --- filtermail/src/inbound.rs | 7 ++----- filtermail/src/outbound.rs | 5 ++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index def5d2cb..fb554dde 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -7,21 +7,18 @@ use crate::smtp_server::SmtpHandler; use async_trait::async_trait; use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; use mailparse::{MailHeaderMap, parse_mail}; -use std::sync::Arc; pub use crate::smtp_server::Envelope; use crate::utils::{extract_address, format_smtp_error}; /// Handler for incoming SMTP messages. pub struct IncomingBeforeQueueHandler { - config: Arc, + config: Config, } impl IncomingBeforeQueueHandler { pub fn new(config: Config) -> Self { - Self { - config: Arc::new(config), - } + Self { config } } } diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 37858c34..da7381be 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -10,11 +10,10 @@ use async_trait::async_trait; use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; use mailparse::{MailHeaderMap, parse_mail}; -use std::sync::Arc; /// Handler for outgoing SMTP messages. pub struct OutgoingBeforeQueueHandler { - config: Arc, + config: Config, send_rate_limiter: DefaultKeyedRateLimiter, } @@ -23,7 +22,7 @@ impl OutgoingBeforeQueueHandler { let quota = Quota::per_minute(config.max_user_send_per_minute) .allow_burst(config.max_user_send_burst_size); Self { - config: Arc::new(config), + config, send_rate_limiter: RateLimiter::keyed(quota), } } From 0ee1e9924c201238f24a533770b3d20bb64c0025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:15:14 +0100 Subject: [PATCH 036/166] refactor: Use a custom, minimal SMTP client instead of lettre (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This disables the Nagle's algorithm on re-insertion. Signed-off-by: Jagoda Ślązak --- filtermail/Cargo.lock | 501 +--------------------------------- filtermail/Cargo.toml | 4 - filtermail/src/error.rs | 19 ++ filtermail/src/inbound.rs | 31 +-- filtermail/src/main.rs | 1 + filtermail/src/outbound.rs | 31 +-- filtermail/src/smtp_client.rs | 78 ++++++ filtermail/src/utils.rs | 24 -- 8 files changed, 119 insertions(+), 570 deletions(-) create mode 100644 filtermail/src/smtp_client.rs diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index c5739c96..1737ac71 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -2,18 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -79,15 +67,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "ar_archive_writer" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a" -dependencies = [ - "object", -] - [[package]] name = "async-trait" version = "0.1.89" @@ -123,16 +102,6 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" -[[package]] -name = "cc" -version = "1.2.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" -dependencies = [ - "find-msvc-tools", - "shlex", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -149,16 +118,6 @@ dependencies = [ "encoding_rs", ] -[[package]] -name = "chumsky" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" -dependencies = [ - "hashbrown 0.14.5", - "stacker", -] - [[package]] name = "colorchoice" version = "1.0.4" @@ -191,23 +150,6 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "email_address" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -264,7 +206,6 @@ dependencies = [ "base64", "env_logger", "governor", - "lettre", "log", "mailparse", "rstest", @@ -275,39 +216,18 @@ dependencies = [ "tokio", ] -[[package]] -name = "find-msvc-tools" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" - [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - [[package]] name = "futures-macro" version = "0.3.31" @@ -344,11 +264,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", - "futures-io", "futures-macro", "futures-sink", "futures-task", - "memchr", "pin-project-lite", "pin-utils", "slab", @@ -402,10 +320,6 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" @@ -418,108 +332,6 @@ dependencies = [ "foldhash", ] -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "indexmap" version = "2.13.0" @@ -570,38 +382,12 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lettre" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" -dependencies = [ - "async-trait", - "base64", - "chumsky", - "email_address", - "futures-io", - "futures-util", - "idna", - "nom", - "percent-encoding", - "socket2", - "tokio", - "url", -] - [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - [[package]] name = "lock_api" version = "0.4.14" @@ -645,30 +431,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - [[package]] name = "nonzero_ext" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" -[[package]] -name = "object" -version = "0.32.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -704,12 +472,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - [[package]] name = "pin-project-lite" version = "0.2.16" @@ -737,15 +499,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -773,16 +526,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "psm" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01" -dependencies = [ - "ar_archive_writer", - "cc", -] - [[package]] name = "quanta" version = "0.12.6" @@ -997,12 +740,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1044,25 +781,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "stacker" -version = "0.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" -dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.59.0", -] - [[package]] name = "syn" version = "2.0.114" @@ -1074,17 +792,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "testresult" version = "0.4.1" @@ -1111,16 +818,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "tokio" version = "1.49.0" @@ -1185,36 +882,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[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 = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1323,22 +996,13 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -1350,22 +1014,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - [[package]] name = "windows-targets" version = "0.53.5" @@ -1373,106 +1021,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -1494,35 +1094,6 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - [[package]] name = "zerocopy" version = "0.8.33" @@ -1542,57 +1113,3 @@ dependencies = [ "quote", "syn", ] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index af268a99..afa5bc4c 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -16,10 +16,6 @@ serini = "0.2.2" tokio = { version = "1.49.0", features = ["full"] } thiserror = "2.0.18" mailparse = "0.16.1" -lettre = { version = "0.11.19", default-features = false, features = [ - "smtp-transport", - "tokio1", -] } log = "0.4.29" env_logger = "0.11.8" governor = "0.10.4" diff --git a/filtermail/src/error.rs b/filtermail/src/error.rs index 4f6c9c80..76dd251d 100644 --- a/filtermail/src/error.rs +++ b/filtermail/src/error.rs @@ -10,4 +10,23 @@ pub enum Error { Io(#[from] std::io::Error), #[error("OpenPGP packet header is truncated - can't validate!")] TruncatedHeader, + #[error("Unable to send email, Error during {context}, server said: {raw_smtp_answer}")] + MailSend { + context: String, + raw_smtp_answer: String, + }, +} + +impl Error { + /// Formats [`Error`] as an SMTP response. + pub fn smtp_response(&self) -> String { + match self { + // We transparently pass postfix/milter errors reported on reinjection + Error::MailSend { + raw_smtp_answer, .. + } => raw_smtp_answer.clone(), + Error::TruncatedHeader => self.to_string(), + _ => "451 Local error".to_string(), + } + } } diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index fb554dde..7d295b38 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -5,11 +5,10 @@ use crate::config::Config; use crate::message::{check_encrypted, is_securejoin}; use crate::smtp_server::SmtpHandler; use async_trait::async_trait; -use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; use mailparse::{MailHeaderMap, parse_mail}; pub use crate::smtp_server::Envelope; -use crate::utils::{extract_address, format_smtp_error}; +use crate::utils::extract_address; /// Handler for incoming SMTP messages. pub struct IncomingBeforeQueueHandler { @@ -80,30 +79,12 @@ impl SmtpHandler for IncomingBeforeQueueHandler { async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { log::debug!("Re-injecting the mail that passed checks"); - let mailer = AsyncSmtpTransport::::builder_dangerous("localhost") - .port(self.config.postfix_reinject_port_incoming) - .build(); - - let envelope_data = lettre::address::Envelope::new( - Some( - envelope - .mail_from - .parse() - .map_err(|e| format!("Invalid from address: {}", e))?, - ), - envelope - .rcpt_to - .iter() - .map(|addr| addr.parse()) - .collect::, _>>() - .map_err(|e| format!("Invalid to address: {}", e))?, - ) - .map_err(|e| format!("Failed to create envelope: {}", e))?; - - mailer - .send_raw(&envelope_data, &envelope.data) + crate::smtp_client::send(self.config.postfix_reinject_port_incoming, envelope) .await - .map_err(format_smtp_error)?; + .map_err(|e| { + log::warn!("Failed to re-inject mail: {}", e); + e.smtp_response() + })?; Ok(()) } diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index 12b2048d..62db8c79 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -30,6 +30,7 @@ pub(crate) mod inbound; pub(crate) mod message; pub(crate) mod openpgp; pub(crate) mod outbound; +pub(crate) mod smtp_client; pub(crate) mod smtp_server; pub(crate) mod utils; diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index da7381be..22261aae 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -5,10 +5,9 @@ use crate::config::Config; use crate::message::{check_encrypted, is_securejoin, recipient_matches_passthrough}; pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; -use crate::utils::{extract_address, format_smtp_error}; +use crate::utils::extract_address; use async_trait::async_trait; use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; -use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; use mailparse::{MailHeaderMap, parse_mail}; /// Handler for outgoing SMTP messages. @@ -127,30 +126,12 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { log::debug!("Re-injecting the mail that passed checks"); - let mailer = AsyncSmtpTransport::::builder_dangerous("localhost") - .port(self.config.postfix_reinject_port) - .build(); - - let envelope_data = lettre::address::Envelope::new( - Some( - envelope - .mail_from - .parse() - .map_err(|e| format!("Invalid from address: {}", e))?, - ), - envelope - .rcpt_to - .iter() - .map(|addr| addr.parse()) - .collect::, _>>() - .map_err(|e| format!("Invalid to address: {}", e))?, - ) - .map_err(|e| format!("Failed to create envelope: {}", e))?; - - mailer - .send_raw(&envelope_data, &envelope.data) + crate::smtp_client::send(self.config.postfix_reinject_port, envelope) .await - .map_err(format_smtp_error)?; + .map_err(|e| { + log::warn!("Failed to re-inject mail: {}", e); + e.smtp_response() + })?; Ok(()) } diff --git a/filtermail/src/smtp_client.rs b/filtermail/src/smtp_client.rs new file mode 100644 index 00000000..a2dee266 --- /dev/null +++ b/filtermail/src/smtp_client.rs @@ -0,0 +1,78 @@ +use crate::smtp_server::Envelope; +use std::net::{IpAddr, SocketAddr}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufStream}; +use tokio::net::TcpSocket; + +const LOCALHOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)); + +/// Sends an email using an SMTP server at `localhost:`. +pub async fn send(smtp_port: u16, envelope: &Envelope) -> Result<(), crate::error::Error> { + let socket = TcpSocket::new_v4()?; + + // Disable Nagle's algorithm. + socket.set_nodelay(true)?; + + let stream = socket + .connect(SocketAddr::new(LOCALHOST, smtp_port)) + .await?; + + let mut buf_stream = BufStream::new(stream); + let mut response = String::new(); + + macro_rules! smtp_write { + ($command: expr) => { + buf_stream.write_all($command).await?; + buf_stream.flush().await?; + }; + } + + macro_rules! smtp_read { + ($context:expr, $expected_code:expr) => { + buf_stream.read_line(&mut response).await?; + if !response.starts_with($expected_code) { + return Err(crate::error::Error::MailSend { + context: $context.to_string(), + raw_smtp_answer: response.clone(), + }); + } + response.clear(); + }; + } + + macro_rules! smtp_cmd { + ($command:expr, $context:expr, $expected_code:expr) => { + smtp_write!($command); + smtp_read!($context, $expected_code); + }; + } + + // Read initial greeting + smtp_read!("initial greeting", "220"); + + // Greet (Using HELO as we don't want to deal with extended SMTP anyway.) + smtp_cmd!(b"HELO localhost\r\n", "HELO", "250"); + + // MAIL FROM + smtp_cmd!( + format!("MAIL FROM:<{}>\r\n", envelope.mail_from).as_bytes(), + "MAIL FROM", + "250" + ); + + // RCPT TO + for rcpt in &envelope.rcpt_to { + smtp_cmd!( + format!("RCPT TO:<{}>\r\n", rcpt).as_bytes(), + "RCPT TO", + "250" + ); + } + + // DATA + smtp_cmd!(b"DATA\r\n", "DATA", "354"); + smtp_write!(&envelope.data); + smtp_write!(b".\r\n"); + smtp_read!("end of DATA", "250"); + + Ok(()) +} diff --git a/filtermail/src/utils.rs b/filtermail/src/utils.rs index 356270c7..563cad1e 100644 --- a/filtermail/src/utils.rs +++ b/filtermail/src/utils.rs @@ -1,5 +1,4 @@ use mailparse::MailAddr; -use std::error::Error; /// Extracts the first email address found in SMTP command or email header. /// @@ -27,29 +26,6 @@ pub fn extract_address(input: &str) -> Option { }) } -/// Formats SMTP error to be able to send it back to postfix. -pub fn format_smtp_error(error: lettre::transport::smtp::Error) -> String { - if let Some(code) = error.status() { - format!( - "{} {}", - code, - error - .source() - .map(ToString::to_string) - .unwrap_or("Unknown error".to_string()) - ) - } else { - // Default to 451, most probably means some internal service error (e.g. milter) - format!( - "451 {}", - error - .source() - .map(ToString::to_string) - .unwrap_or("Unknown error".to_string()) - ) - } -} - #[cfg(test)] mod tests { use super::*; From c1f728535f423684f1788ae59c47f1ef43ed577f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:29:31 +0100 Subject: [PATCH 037/166] chore(deps): Bump taiki-e/install-action from 2.67.16 to 2.67.24 (#37) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.67.16 to 2.67.24. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/a362280ea100b5bb920cf9040c038ce9a50c7943...4573997e3b6c0fbc22f7acab193b00a8a3ca817d) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.67.24 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index fd5531e1..80825424 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - - uses: taiki-e/install-action@a362280ea100b5bb920cf9040c038ce9a50c7943 # v2 + - uses: taiki-e/install-action@4573997e3b6c0fbc22f7acab193b00a8a3ca817d # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check From cc1604b0b26d9c09845a36e8f52b8e9712c85ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:31:39 +0100 Subject: [PATCH 038/166] feat: Support legacy, pre-OpenPGP packet format (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support legacy packet format for compatibility with GnuPG. Closes: #43 Signed-off-by: Jagoda Ślązak --- filtermail/src/openpgp.rs | 167 +++++++++++++++++++++++++++++--------- 1 file changed, 130 insertions(+), 37 deletions(-) diff --git a/filtermail/src/openpgp.rs b/filtermail/src/openpgp.rs index 04dd85b5..c66bf40b 100644 --- a/filtermail/src/openpgp.rs +++ b/filtermail/src/openpgp.rs @@ -23,48 +23,122 @@ macro_rules! get_byte { /// /// Returns an [`error::Error::TruncatedHeader`] if the OpenPGP packet header is truncated. fn check_openpgp_payload(payload: &[u8]) -> Result { + // From RFC9580; we have to support legacy format too, for compatibility with GnuPG. + // + // +---------------+ + // Encoded Packet Type ID: |7 6 5 4 3 2 1 0| + // +---------------+ + // OpenPGP format: + // Bit 7 -- always one + // Bit 6 -- always one + // Bits 5 to 0 -- Packet Type ID + // + // Legacy format: + // Bit 7 -- always one + // Bit 6 -- always zero + // Bits 5 to 2 -- Packet Type ID + // Bits 1 to 0 -- length-type + let mut i: usize = 0; while i < payload.len() { - // Only OpenPGP format is allowed. - if (get_byte!(payload, i) & 0xC0) != 0xC0 { - log::debug!("check_openpgp_payload: i={i} Not OpenPGP format"); - return Ok(false); - } - - let packet_type_id = get_byte!(payload, i) & 0x3F; - i += 1; - - while get_byte!(payload, i) >= 224 && get_byte!(payload, i) < 255 { - // Partial body length. - let partial_length = 1usize << (get_byte!(payload, i) & 0x1F); - i += 1 + partial_length; - } - - let body_len: usize; - if get_byte!(payload, i) < 192 { - // One-octet length. - body_len = get_byte!(payload, i) as usize; - i += 1; - } else if get_byte!(payload, i) < 224 { - // Two-octet length. - body_len = (((get_byte!(payload, i) as usize) - 192) << 8) - + (get_byte!(payload, i + 1) as usize) - + 192; - i += 2; - } else if get_byte!(payload, i) == 255 { - // Five-octet length. - body_len = ((get_byte!(payload, i + 1) as usize) << 24) - | ((get_byte!(payload, i + 2) as usize) << 16) - | ((get_byte!(payload, i + 3) as usize) << 8) - | (get_byte!(payload, i + 4) as usize); - i += 5; + let bits_7_6 = get_byte!(payload, i) & 0xC0; + // bit 6 is 0 for legacy (GnuPG) + let legacy = if bits_7_6 == 0xC0 { + false + } else if bits_7_6 == 0x80 { + true } else { - // Impossible, partial body length was processed above. - log::debug!("check_openpgp_payload: i={i} Invalid body length"); + log::debug!( + "check_openpgp_payload: i={i} bits 7 and 6 doesn't indicate OpenPGP or legacy format" + ); return Ok(false); - } + }; - i += body_len; + let packet_type_id = if legacy { + // bits 5 to 2 + (get_byte!(payload, i) & 0x3C) >> 2 + } else { + // bits 5 to 0 + get_byte!(payload, i) & 0x3F + }; + + if legacy { + // Body length calculation - Legacy, RFC9580 4.2.2 + + let length_type = get_byte!(payload, i) & 0x03; + i += 1; + + let body_len: usize; + match length_type { + 0 => { + // One-octet length. + body_len = get_byte!(payload, i) as usize; + i += 1; + } + 1 => { + // Two-octet length. + body_len = ((get_byte!(payload, i) as usize) << 8) + + (get_byte!(payload, i + 1) as usize); + i += 2; + } + 2 => { + // Four-octet length. + body_len = ((get_byte!(payload, i) as usize) << 24) + + ((get_byte!(payload, i + 1) as usize) << 16) + + ((get_byte!(payload, i + 2) as usize) << 8) + + (get_byte!(payload, i + 3) as usize); + i += 4; + } + // 3 - indeterminate length, not supported + // RFC9580 4.2.2: + // "An implementation MUST NOT generate a Legacy format packet with indeterminate length." + // We hope they don't. + _ => { + log::debug!( + "check_openpgp_payload: i={i} Indeterminate length (3) length-type in legacy packet format" + ); + return Ok(false); + } + }; + + i += body_len; + } else { + // Body length calculation - OpenPGP, RFC9580 4.2.1 + + i += 1; + + while get_byte!(payload, i) >= 224 && get_byte!(payload, i) < 255 { + // Partial body length. + let partial_length = 1usize << (get_byte!(payload, i) & 0x1F); + i += 1 + partial_length; + } + + let body_len: usize; + if get_byte!(payload, i) < 192 { + // One-octet length. + body_len = get_byte!(payload, i) as usize; + i += 1; + } else if get_byte!(payload, i) < 224 { + // Two-octet length. + body_len = (((get_byte!(payload, i) as usize) - 192) << 8) + + (get_byte!(payload, i + 1) as usize) + + 192; + i += 2; + } else if get_byte!(payload, i) == 255 { + // Five-octet length. + body_len = ((get_byte!(payload, i + 1) as usize) << 24) + | ((get_byte!(payload, i + 2) as usize) << 16) + | ((get_byte!(payload, i + 3) as usize) << 8) + | (get_byte!(payload, i + 4) as usize); + i += 5; + } else { + // Impossible, partial body length was processed above. + log::debug!("check_openpgp_payload: i={i} Invalid body length"); + return Ok(false); + } + + i += body_len; + } if i == payload.len() { // Last packet should be @@ -168,6 +242,25 @@ uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn lkOWnEbCD+XTnbDd =agR5 +-----END PGP MESSAGE-----"#, (true, true))] + // GnuPG uses a legacy packet format that was obsoleted back in 1998 (RFC2440); + // despite RFC9580 stating "The Legacy packet format SHOULD NOT be used to generate new data". + #[case::gnupg(r#"-----BEGIN PGP MESSAGE----- + +hF4DFYw3E47UG/ASAQdA3JnkJLJJALitYG/jzzEuF0p+hEBsgLTicaEJkJerInkw +SftuCMBjxv4D6rTqNkBn7IQh6gmlUKrUNBVEnq0V1InVF9o0kDpZfUUaB5ajNDMb +hF4DMiMKov7/CVgSAQdA7ib6HK7UVW2IdAM5PILbtqzuynukooXdg9YWWamjal8w +h/L0BxbtUFzI3U847dvzwtsW7A9CZXpi45OMXRC9mO3G/FAY1fW+C6PwObT7X1qK +0sDSAbyYddCihL1UEl6EigtD/YQezCTCydzstD2cRdV75lmUqzMCR8NbfvdttBbl +g04ZZtoHm1DSnXIAN4iFzW4EBgNVUDCnqO8wyYmWw4OCOt45IkTXGQUICGxeVh6g +4Pdj4OzSrg/mTZGIDjE2h/osVpPEQjwcAus73EZC4sz4n2U+OMHsq0M54Ds7wyGv +1KBzcQQ5Rc/DPPT0INnl58datm2HGKtWFWYab9/cw/fnQ+oYhmpkZbdITTmnTAyn +CHih86i0w6gXsX99iRLe6yP8BcU3rCCflsCp5zzlcFYViTPgEn1W8H32VOXulfcV +rpRFtc5yzjFj8Dvq9gI9yGIgqiS7oEAXdRBYhmBRTydMcbS1bCPDWy5w95Cp2VzR +x8cBq5e+27QTl9YNHr4xeric7del79wktJFiINzUCQPwkwM9ud2EJ1zN8Owr2Dp8 +JvS2ioSEK5J2Lc95vDLmbVnMuKmVW6qD+fEdtYSHnSIc/t+iYG3GlaFyvO8t0Jav +cbsijrbYhoSOq67KseQWvfL+gL5u +=WWqc -----END PGP MESSAGE-----"#, (true, true))] #[case::with_comment(r#"-----BEGIN PGP MESSAGE----- Version: 1 From 10fd1481558e97a96a29a7c5fd52519c819a0d76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:34:36 +0100 Subject: [PATCH 039/166] chore(deps): Bump env_logger from 0.11.8 to 0.11.9 (#47) Bumps [env_logger](https://github.com/rust-cli/env_logger) from 0.11.8 to 0.11.9. - [Release notes](https://github.com/rust-cli/env_logger/releases) - [Changelog](https://github.com/rust-cli/env_logger/blob/main/CHANGELOG.md) - [Commits](https://github.com/rust-cli/env_logger/compare/v0.11.8...v0.11.9) --- updated-dependencies: - dependency-name: env_logger dependency-version: 0.11.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/Cargo.lock | 8 ++++---- filtermail/Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 1737ac71..af6ceee9 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -161,9 +161,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", @@ -171,9 +171,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ "anstream", "anstyle", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index afa5bc4c..d9f06a97 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -17,7 +17,7 @@ tokio = { version = "1.49.0", features = ["full"] } thiserror = "2.0.18" mailparse = "0.16.1" log = "0.4.29" -env_logger = "0.11.8" +env_logger = "0.11.9" governor = "0.10.4" [dev-dependencies] From 21f0614b5d072057cb1fbcda7490ef01047acda3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:35:33 +0100 Subject: [PATCH 040/166] chore(deps): Bump taiki-e/install-action from 2.67.24 to 2.67.30 (#48) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.67.24 to 2.67.30. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/4573997e3b6c0fbc22f7acab193b00a8a3ca817d...288875dd3d64326724fa6d9593062d9f8ba0b131) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.67.30 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 80825424..d34de601 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - - uses: taiki-e/install-action@4573997e3b6c0fbc22f7acab193b00a8a3ca817d # v2 + - uses: taiki-e/install-action@288875dd3d64326724fa6d9593062d9f8ba0b131 # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check From 85d6c381b3d7bf641ff38b69248ed0047313e575 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:36:27 +0100 Subject: [PATCH 041/166] chore(deps): Bump dtolnay/rust-toolchain (#46) Bumps [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 to efa25f7f19611383d5b0ccf2d1c8914531636bf9. - [Release notes](https://github.com/dtolnay/rust-toolchain/releases) - [Commits](https://github.com/dtolnay/rust-toolchain/compare/f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561...efa25f7f19611383d5b0ccf2d1c8914531636bf9) --- updated-dependencies: - dependency-name: dtolnay/rust-toolchain dependency-version: efa25f7f19611383d5b0ccf2d1c8914531636bf9 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index d34de601..56f2e578 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master + - uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master with: toolchain: nightly components: rustfmt @@ -50,7 +50,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master + - uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master with: toolchain: ${{ matrix.toolchain }} components: clippy @@ -70,7 +70,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master + - uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master with: toolchain: stable - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 From 3bf8395ef6744d685552d2975fa67f5904249223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:42:55 +0100 Subject: [PATCH 042/166] chore(release): prepare for 0.3.0 (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 14 ++++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index 8c2b524e..c63387e0 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,17 @@ +## 0.3.0 - 2026-02-14 + +### Features + +- Support legacy, pre-OpenPGP packet format (#44) + +### Miscellaneous Tasks + +- *(dist)* Switch to musl targets (#31) + +### Refactor + +- Remove unnecessary Arc (#36) +- Use a custom, minimal SMTP client instead of lettre (#33) ## 0.2.0 - 2026-01-28 ### Features diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index af6ceee9..88642f56 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -200,7 +200,7 @@ dependencies = [ [[package]] name = "filtermail" -version = "0.2.0" +version = "0.3.0" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index d9f06a97..7b343700 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "MIT" readme = "README.md" From a096d0550f1386f7aa2b31814be6d65477e3f822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:50:55 +0100 Subject: [PATCH 043/166] feat!: DKIM verifier (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat!: DKIM verifier This implements a DKIM verification as well as a strict DKIM signature alignment check with domain in `From` header address. Caches the retrieved RDATA using in-memory LRU. BREAKING CHANGE: incoming messages now require DKIM signatures aligned to domain of the `From` header address. Signed-off-by: Jagoda Ślązak * chore(license): License binaries under GPLv3 Signed-off-by: Jagoda Ślązak --------- Signed-off-by: Jagoda Ślązak --- filtermail/Cargo.lock | 1123 ++++++++++++++++++++++++++++++- filtermail/Cargo.toml | 4 + filtermail/LICENSE-GPL | 674 +++++++++++++++++++ filtermail/README.md | 6 + filtermail/src/config.rs | 2 +- filtermail/src/dkim_verifier.rs | 221 ++++++ filtermail/src/error.rs | 2 + filtermail/src/inbound.rs | 47 +- filtermail/src/main.rs | 6 +- filtermail/src/outbound.rs | 2 +- filtermail/src/smtp_server.rs | 4 +- filtermail/src/utils.rs | 21 + 12 files changed, 2072 insertions(+), 40 deletions(-) create mode 100644 filtermail/LICENSE-GPL create mode 100644 filtermail/src/dkim_verifier.rs diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 88642f56..fa4ccd03 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -78,18 +78,39 @@ dependencies = [ "syn", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -102,6 +123,16 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -124,12 +155,88 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -150,6 +257,63 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -159,6 +323,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "env_filter" version = "1.0.0" @@ -198,6 +374,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filtermail" version = "0.3.0" @@ -206,28 +388,62 @@ dependencies = [ "base64", "env_logger", "governor", + "hickory-resolver", "log", + "lru", "mailparse", + "parking_lot", "rstest", "serde", "serini", "testresult", "thiserror", "tokio", + "viadkim", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-macro" version = "0.3.31" @@ -272,6 +488,27 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -303,13 +540,13 @@ dependencies = [ "futures-sink", "futures-timer", "futures-util", - "getrandom", + "getrandom 0.3.4", "hashbrown 0.16.1", "nonzero_ext", "parking_lot", "portable-atomic", "quanta", - "rand", + "rand 0.9.2", "smallvec", "spinning_top", "web-time", @@ -332,6 +569,170 @@ dependencies = [ "foldhash", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna 1.1.0", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -342,6 +743,24 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -382,12 +801,33 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "lock_api" version = "0.4.14" @@ -403,6 +843,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "mailparse" version = "0.16.1" @@ -431,17 +880,84 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "nonzero_ext" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -472,6 +988,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -484,6 +1015,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "portable-atomic" version = "1.13.0" @@ -499,6 +1051,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -562,14 +1123,34 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -579,7 +1160,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -588,7 +1178,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] [[package]] @@ -644,6 +1234,46 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rstest" version = "0.26.1" @@ -740,6 +1370,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -750,6 +1397,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "slab" version = "0.4.11" @@ -762,6 +1419,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.1" @@ -772,6 +1439,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spinning_top" version = "0.3.0" @@ -781,6 +1454,28 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.114" @@ -792,6 +1487,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "testresult" version = "0.4.1" @@ -818,6 +1530,31 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -830,7 +1567,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.1", "tokio-macros", "windows-sys 0.61.2", ] @@ -876,18 +1613,128 @@ dependencies = [ "winnow", ] +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna 1.1.0", + "percent-encoding", + "serde", +] + +[[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 = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "viadkim" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fbe3d7dd3cf3431b0151b92997000d346dba564c229c5933ef1a9cc16f13949" +dependencies = [ + "base64ct", + "digest", + "ed25519-dalek", + "idna 0.4.0", + "pkcs8", + "rsa", + "sha2", + "tokio", + "tracing", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -968,6 +1815,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -996,13 +1849,31 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -1014,6 +1885,37 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -1021,58 +1923,148 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -1088,12 +2080,51 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.33" @@ -1113,3 +2144,63 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 7b343700..d533da06 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -19,6 +19,10 @@ mailparse = "0.16.1" log = "0.4.29" env_logger = "0.11.9" governor = "0.10.4" +viadkim = { version = "0.2.0" } +hickory-resolver = "0.25.2" +lru = "0.16.3" +parking_lot = "0.12.5" [dev-dependencies] rstest = "0.26.1" diff --git a/filtermail/LICENSE-GPL b/filtermail/LICENSE-GPL new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/filtermail/LICENSE-GPL @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/filtermail/README.md b/filtermail/README.md index 5e37f97c..e7eeac39 100644 --- a/filtermail/README.md +++ b/filtermail/README.md @@ -7,3 +7,9 @@ Rust drop-in reimplementation of chatmaild's filtermail. ```plain filtermail (incoming|outgoing) ``` + +## License + +Code licensed under [MIT](LICENSE). + +Binaries distributed under [GPL-3.0-or-later](LICENSE). diff --git a/filtermail/src/config.rs b/filtermail/src/config.rs index 96f7f08e..f6f52909 100644 --- a/filtermail/src/config.rs +++ b/filtermail/src/config.rs @@ -25,7 +25,7 @@ pub struct Config { pub passthrough_senders: Vec, #[serde(default, deserialize_with = "deserialize_sequence")] pub passthrough_recipients: Vec, - mail_domain: String, + pub mail_domain: String, mailboxes_dir: Option, } diff --git a/filtermail/src/dkim_verifier.rs b/filtermail/src/dkim_verifier.rs new file mode 100644 index 00000000..c8ffdaf4 --- /dev/null +++ b/filtermail/src/dkim_verifier.rs @@ -0,0 +1,221 @@ +use crate::utils::get_domain_from_address; +use hickory_resolver::name_server::TokioConnectionProvider; +use hickory_resolver::{Name, TokioResolver}; +use lru::LruCache; +use std::io; +use std::num::NonZeroUsize; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; +use viadkim::VerificationStatus; +use viadkim::message_hash::BodyHasherStance; +use viadkim::verifier::LookupTxt; + +// ~500kB when fully saturated (~420B per RDATA + selector). +// "top 1000 relays" is much more than enough, the limit is mostly to prevent DoS attacks. +const LRU_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).expect("1000 != 0"); + +/// DNS resolver for DKIM TXT records, that caches RDATA in memory. +#[derive(Clone)] +struct CachedResolver { + dns_resolver: TokioResolver, + // Note: Arc is required despite we are holding the whole handler in an Arc, + // because viadkim will internally clone the resolver (LookupTxt + Clone + 'static) + // to parallelize lookups in case of multiple signatures... + cache: Arc>>>, +} + +impl CachedResolver { + /// Creates a new [`CachedResolver`]. + pub fn new() -> Result { + // Use resolv.conf + let dns_resolver = TokioResolver::builder(TokioConnectionProvider::default())?.build(); + + let cache = Arc::new(parking_lot::Mutex::new(LruCache::new(LRU_CACHE_CAPACITY))); + + Ok(Self { + dns_resolver, + cache, + }) + } + + /// Normalizes a TXT record RDATA by removing whitespace characters. + /// + /// Some DKIM key records use e.g. LF + WSP line breaks. + /// This is technically not correct, and `viadkim` fails to parse such records, + /// but in practice this is accepted by many implementations. + fn normalize_rdata(txt_data: &str) -> String { + txt_data.replace([' ', '\t', '\n', '\r'], "") + } + + /// Invalidates the cached RDATA for a given selector and domain. + /// + /// Fails silently. + fn invalidate_cache(&self, selector: &str, domain: &str) { + let selector_domain_str = format!("{}._domainkey.{}.", selector, domain); + if let Ok(selector_domain) = Name::from_ascii(&selector_domain_str) { + let mut cache = self.cache.lock(); + cache.pop(&selector_domain); + log::debug!("Cache invalidated for {}", selector_domain_str); + } else { + log::warn!( + "Failed to parse selector domain for cache invalidation: {}", + selector_domain_str + ); + } + } +} + +impl LookupTxt for CachedResolver { + type Answer = Box>>>; + type Query<'a> = Pin> + Send + 'a>>; + + fn lookup_txt(&self, domain: &str) -> Self::Query<'_> { + let name = Name::from_ascii(domain); + Box::pin(async move { + let name = name.map_err(|_| io::ErrorKind::InvalidInput)?; + + { + let mut cache = self.cache.lock(); + if let Some(rdata) = cache.get(&name) { + let txts: Self::Answer = Box::new(std::iter::once(Ok(rdata.clone()))); + log::debug!("Using cached RDATA for {}", name); + return Ok(txts); + } + } + + log::debug!("Trying to resolve TXT for {}", name); + let txt = { + let lookup = self.dns_resolver.txt_lookup(name.clone()).await?; + + // viadkim would filter out non-DKIM TXT records, + // but we filter it here anyway so that we know which one should be cached. + lookup + .into_iter() + .find_map(|txt| { + let rdata_raw = txt.txt_data().concat(); + let rdata = String::from_utf8_lossy(&rdata_raw); + // naive check, but this can't be an attack vector, + // and selector domain should only have a single TXT record anyway. + if rdata.contains("DKIM") { + Some(Self::normalize_rdata(&rdata).into_bytes()) + } else { + None + } + }) + .ok_or(io::ErrorKind::NotFound)? + }; + + { + let mut cache = self.cache.lock(); + cache.put(name, txt.clone()); + } + + let txts: Self::Answer = Box::new(std::iter::once(Ok(txt))); + Ok(txts) + }) + } +} + +/// DKIM verifier using a pre-configured [`viadkim`] verifier, a [`CachedResolver`] for DNS lookups, +/// and strict domain name alignment check. +pub struct DkimVerifier { + resolver: CachedResolver, + config: viadkim::Config, +} + +impl DkimVerifier { + /// Creates a new [`DkimVerifier`] with the provided resolver. + pub fn new() -> Result { + let resolver = CachedResolver::new()?; + let config = viadkim::Config { + lookup_timeout: Duration::from_secs(60), + ..Default::default() + }; + Ok(Self { resolver, config }) + } + + /// Verifies the DKIM signature of a raw email message and alignment with the domain of + /// provided `From` address. + pub async fn verify(&self, raw_mail: &[u8], from_address: &str) -> Result<(), String> { + let (headers, body_start) = { + use viadkim::{FieldBody, FieldName, HeaderField}; + + let Ok((headers, body_start)) = mailparse::parse_headers(raw_mail) else { + return Err("500 Failed to parse message headers".to_string()); + }; + + let mut viadkim_headers: Vec = Vec::new(); + for header in headers { + match ( + FieldName::new(header.get_key()), + FieldBody::new(header.get_value_raw()), + ) { + (Ok(name), Ok(body)) => viadkim_headers.push((name, body)), + (Err(e), _) | (_, Err(e)) => { + log::debug!("Failed to parse header {header:?}, skipping: {e}"); + } + } + } + + let viadkim_headers = viadkim::HeaderFields::new(viadkim_headers).map_err(|e| { + log::error!("Failed to parse headers for DKIM verification: {e}"); + "500 Failed to parse message headers".to_string() + })?; + + (viadkim_headers, body_start) + }; + + let Some(from_domain) = get_domain_from_address(from_address) else { + return Err("501 Invalid From address".to_string()); + }; + + log::debug!("`From` header domain: {from_domain}"); + + let Some(mut verifier) = + viadkim::Verifier::verify_header(&self.resolver, &headers, &self.config).await + else { + return Err("554 5.7.1 No DKIM signature found".to_string()); + }; + + 'hasher: for chunk in raw_mail.get(body_start..).unwrap_or_default().chunks(8192) { + if verifier.process_body_chunk(chunk) == BodyHasherStance::Done { + break 'hasher; + } + } + + for res in verifier.finish() { + log::debug!("Signature {}: {:?}", res.index, res.status); + + let Some(signature) = &res.signature else { + log::debug!("Signature {}: No signature found, skipping", res.index); + continue; + }; + + if matches!(res.status, VerificationStatus::Failure(_)) { + log::debug!("Signature {}: Verification failed, skipping", res.index); + // We only invalidate cache on actual validation error, and not alignment error. + // TODO: ideally we should retry without cache and swap cached value only on success. + self.resolver + .invalidate_cache(signature.selector.as_ref(), signature.domain.as_ref()); + continue; + } + + if !signature + .domain + .to_string() + .eq_ignore_ascii_case(&from_domain) + { + log::debug!( + "Signature {}: Domain different than in From header, skipping", + res.index + ); + continue; + } + + return Ok(()); + } + + Err("554 5.7.1 No valid DKIM signature found".to_string()) + } +} diff --git a/filtermail/src/error.rs b/filtermail/src/error.rs index 76dd251d..b51d6c1c 100644 --- a/filtermail/src/error.rs +++ b/filtermail/src/error.rs @@ -8,6 +8,8 @@ pub enum Error { Config(#[from] serini::Error), #[error(transparent)] Io(#[from] std::io::Error), + #[error(transparent)] + Resolve(#[from] hickory_resolver::ResolveError), #[error("OpenPGP packet header is truncated - can't validate!")] TruncatedHeader, #[error("Unable to send email, Error during {context}, server said: {raw_smtp_answer}")] diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 7d295b38..82757c6b 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -2,22 +2,26 @@ use crate::ENCRYPTION_NEEDED_523; use crate::config::Config; +use crate::dkim_verifier::DkimVerifier; use crate::message::{check_encrypted, is_securejoin}; +pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; +use crate::utils::extract_address; use async_trait::async_trait; use mailparse::{MailHeaderMap, parse_mail}; -pub use crate::smtp_server::Envelope; -use crate::utils::extract_address; - /// Handler for incoming SMTP messages. pub struct IncomingBeforeQueueHandler { config: Config, + dkim_verifier: DkimVerifier, } impl IncomingBeforeQueueHandler { - pub fn new(config: Config) -> Self { - Self { config } + pub fn new(config: Config) -> Result { + Ok(Self { + config, + dkim_verifier: DkimVerifier::new()?, + }) } } @@ -27,7 +31,7 @@ impl SmtpHandler for IncomingBeforeQueueHandler { Ok(()) } - fn check_data(&self, envelope: &Envelope) -> Result<(), String> { + async fn check_data(&self, envelope: &Envelope) -> Result<(), String> { log::debug!("Processing DATA message from {}", envelope.mail_from); let message = match parse_mail(&envelope.data) { @@ -35,6 +39,21 @@ impl SmtpHandler for IncomingBeforeQueueHandler { Err(e) => return Err(format!("500 Failed to parse message: {}", e)), }; + let from_header = message + .headers + .get_first_value("From") + .unwrap_or_default() + .trim() + .to_string(); + + let Some(from_addr) = extract_address(&from_header) else { + return Err(format!("500 Invalid FROM header: {from_header}")); + }; + + self.dkim_verifier + .verify(&envelope.data, &from_addr) + .await?; + let mail_encrypted = check_encrypted(&message, false); log::debug!("mail_encrypted: {mail_encrypted}"); log::debug!("is_securejoin: {}", is_securejoin(&message)); @@ -50,20 +69,10 @@ impl SmtpHandler for IncomingBeforeQueueHandler { // Allow cleartext mailer-daemon messages if let Some(auto_submitted) = message.headers.get_first_value("Auto-Submitted") && !auto_submitted.is_empty() + && from_addr.to_lowercase().starts_with("mailer-daemon@") + && message.ctype.mimetype == "multipart/report" { - let from_header = message - .headers - .get_first_value("From") - .unwrap_or_default() - .trim() - .to_string(); - - if let Some(from_addr) = extract_address(&from_header) - && from_addr.to_lowercase().starts_with("mailer-daemon@") - && message.ctype.mimetype == "multipart/report" - { - return Ok(()); - } + return Ok(()); } for recipient in &envelope.rcpt_to { diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index 62db8c79..ac30f2dc 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -25,6 +25,7 @@ clippy::bool_to_int_with_if )] mod config; +mod dkim_verifier; pub(crate) mod error; pub(crate) mod inbound; pub(crate) mod message; @@ -95,7 +96,10 @@ async fn main() { process::exit(1); } } else { - let handler = Arc::new(IncomingBeforeQueueHandler::new(config.clone())); + let handler = Arc::new( + // We want to panic here if the handler cannot be created. + IncomingBeforeQueueHandler::new(config.clone()).unwrap(), + ); let addr = format!("127.0.0.1:{}", config.filtermail_smtp_port_incoming); let max_size = config.max_message_size; log::debug!("Incoming SMTP server listening on {addr}"); diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 22261aae..a6f4edbf 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -55,7 +55,7 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { Ok(()) } - fn check_data(&self, envelope: &Envelope) -> Result<(), String> { + async fn check_data(&self, envelope: &Envelope) -> Result<(), String> { log::debug!("Processing DATA message from {}", envelope.mail_from); let message = match parse_mail(&envelope.data) { diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index 04446c97..e5fd0198 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -21,7 +21,7 @@ pub trait SmtpHandler: Send + Sync { fn handle_mail(&self, address: &str) -> Result<(), String>; /// Checks the DATA command before reinjection. - fn check_data(&self, envelope: &Envelope) -> Result<(), String>; + async fn check_data(&self, envelope: &Envelope) -> Result<(), String>; /// Reinjects the mail back to postfix. async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String>; @@ -29,7 +29,7 @@ pub trait SmtpHandler: Send + Sync { /// Handles the DATA command. async fn handle_data(&self, envelope: &Envelope) -> Result { log::debug!("handle_DATA before-queue"); - self.check_data(envelope)?; + self.check_data(envelope).await?; self.reinject_mail(envelope).await.map_err(|e| { log::warn!("Failed to reinject mail: {e}"); e diff --git a/filtermail/src/utils.rs b/filtermail/src/utils.rs index 563cad1e..3b221480 100644 --- a/filtermail/src/utils.rs +++ b/filtermail/src/utils.rs @@ -26,6 +26,17 @@ pub fn extract_address(input: &str) -> Option { }) } +pub fn get_domain_from_address(address: &str) -> Option { + let parts: Vec<&str> = address.split('@').collect(); + if parts.len() == 2 + && let Some(domain) = parts.get(1) + { + Some(domain.to_string()) + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; @@ -44,4 +55,14 @@ mod tests { let result = extract_address(input); assert_eq!(result, expected) } + + #[rstest] + #[case("t1@example.org", Some("example.org".to_string()))] + #[case("SRS1=HHH=example.com==HHH=TT=example.org=alice@example.net", Some("example.net".to_string()))] + #[case("invalid", None)] + #[case("invalid@address@com", None)] + fn test_get_domain_from_address(#[case] input: &str, #[case] expected: Option) { + let result = get_domain_from_address(input); + assert_eq!(result, expected); + } } From 48377542458c81554662347534680c92d759a85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:25:21 +0100 Subject: [PATCH 044/166] feat: Support addresses using domain literals (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the incoming email comes from address that uses domain literals `[]` or `[IPv6:]`, skip DKIM verification and instead check IP alignment with originating IP from XFORWARD command. Signed-off-by: Jagoda Ślązak --- filtermail/src/dkim_verifier.rs | 15 ++----- filtermail/src/error.rs | 3 ++ filtermail/src/inbound.rs | 25 ++++++++++-- filtermail/src/smtp_server.rs | 25 +++++++++++- filtermail/src/utils.rs | 70 +++++++++++++++++++++++++++------ 5 files changed, 109 insertions(+), 29 deletions(-) diff --git a/filtermail/src/dkim_verifier.rs b/filtermail/src/dkim_verifier.rs index c8ffdaf4..5ec7e18d 100644 --- a/filtermail/src/dkim_verifier.rs +++ b/filtermail/src/dkim_verifier.rs @@ -1,4 +1,3 @@ -use crate::utils::get_domain_from_address; use hickory_resolver::name_server::TokioConnectionProvider; use hickory_resolver::{Name, TokioResolver}; use lru::LruCache; @@ -135,9 +134,9 @@ impl DkimVerifier { Ok(Self { resolver, config }) } - /// Verifies the DKIM signature of a raw email message and alignment with the domain of - /// provided `From` address. - pub async fn verify(&self, raw_mail: &[u8], from_address: &str) -> Result<(), String> { + /// Verifies the DKIM signature of a raw email message and its alignment with the provided + /// domain. + pub async fn verify(&self, raw_mail: &[u8], from_domain: &str) -> Result<(), String> { let (headers, body_start) = { use viadkim::{FieldBody, FieldName, HeaderField}; @@ -166,12 +165,6 @@ impl DkimVerifier { (viadkim_headers, body_start) }; - let Some(from_domain) = get_domain_from_address(from_address) else { - return Err("501 Invalid From address".to_string()); - }; - - log::debug!("`From` header domain: {from_domain}"); - let Some(mut verifier) = viadkim::Verifier::verify_header(&self.resolver, &headers, &self.config).await else { @@ -204,7 +197,7 @@ impl DkimVerifier { if !signature .domain .to_string() - .eq_ignore_ascii_case(&from_domain) + .eq_ignore_ascii_case(from_domain) { log::debug!( "Signature {}: Domain different than in From header, skipping", diff --git a/filtermail/src/error.rs b/filtermail/src/error.rs index b51d6c1c..b8593634 100644 --- a/filtermail/src/error.rs +++ b/filtermail/src/error.rs @@ -17,6 +17,8 @@ pub enum Error { context: String, raw_smtp_answer: String, }, + #[error("Invalid email address: {0}")] + InvalidEmailAddress(String), } impl Error { @@ -28,6 +30,7 @@ impl Error { raw_smtp_answer, .. } => raw_smtp_answer.clone(), Error::TruncatedHeader => self.to_string(), + Error::InvalidEmailAddress(address) => format!("500 Invalid email address: {address}"), _ => "451 Local error".to_string(), } } diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 82757c6b..f849f0a1 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -6,9 +6,10 @@ use crate::dkim_verifier::DkimVerifier; use crate::message::{check_encrypted, is_securejoin}; pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; -use crate::utils::extract_address; +use crate::utils::{AddressDomain, extract_address}; use async_trait::async_trait; use mailparse::{MailHeaderMap, parse_mail}; +use std::str::FromStr; /// Handler for incoming SMTP messages. pub struct IncomingBeforeQueueHandler { @@ -50,9 +51,25 @@ impl SmtpHandler for IncomingBeforeQueueHandler { return Err(format!("500 Invalid FROM header: {from_header}")); }; - self.dkim_verifier - .verify(&envelope.data, &from_addr) - .await?; + let from_domain = AddressDomain::from_str(&from_addr).map_err(|e| e.smtp_response())?; + + match from_domain { + AddressDomain::Literal(ip) => { + if !envelope.origin_ip.eq_ignore_ascii_case(&ip) { + log::warn!( + "Received invalid origin address: {ip}, actual: {}", + envelope.origin_ip + ); + return Err(format!( + "500 Invalid FROM domain literal: {ip} does not match origin IP {}", + envelope.origin_ip + )); + } + } + AddressDomain::Name(domain) => { + self.dkim_verifier.verify(&envelope.data, &domain).await?; + } + } let mail_encrypted = check_encrypted(&message, false); log::debug!("mail_encrypted: {mail_encrypted}"); diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index e5fd0198..7efcccf2 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -10,6 +10,7 @@ use tokio::net::{TcpListener, TcpStream}; #[derive(Debug, Clone)] pub struct Envelope { pub mail_from: String, + pub origin_ip: String, pub rcpt_to: Vec, pub data: Vec, } @@ -85,6 +86,7 @@ where let mut envelope = Envelope { mail_from: String::new(), + origin_ip: String::new(), rcpt_to: Vec::new(), data: Vec::new(), }; @@ -106,8 +108,13 @@ where log::debug!("Received: {cmd}"); - if cmd.to_uppercase().starts_with("HELO") || cmd.to_uppercase().starts_with("EHLO") { - writer.write_all(b"250 OK\r\n").await?; + if cmd.to_uppercase().starts_with("HELO") { + writer.write_all(b"250-filtermail\r\n250 OK\r\n").await?; + writer.flush().await?; + } else if cmd.to_uppercase().starts_with("EHLO") { + writer + .write_all(b"250-filtermail\r\n250-XFORWARD ADDR\r\n250 OK\r\n") + .await?; writer.flush().await?; } else if cmd.to_uppercase().starts_with("MAIL FROM:") { if let Some(from) = extract_address(cmd) { @@ -187,9 +194,22 @@ where envelope = Envelope { mail_from: String::new(), + origin_ip: String::new(), rcpt_to: Vec::new(), data: Vec::new(), }; + } else if cmd.to_uppercase().starts_with("XFORWARD") { + // https://www.postfix.org/XFORWARD_README.html + if let Some(addr_part) = cmd + .split_whitespace() + .find(|part| part.to_uppercase().starts_with("ADDR=")) + && let Some(ip) = addr_part.strip_prefix("ADDR=") + { + let ip = ip.to_lowercase(); + envelope.origin_ip = ip.strip_prefix("ipv6:").unwrap_or(&ip).to_string(); + writer.write_all(b"250 OK\r\n").await?; + writer.flush().await?; + } } else if cmd.to_uppercase().starts_with("QUIT") { writer.write_all(b"221 OK\r\n").await?; writer.flush().await?; @@ -197,6 +217,7 @@ where } else if cmd.to_uppercase().starts_with("RSET") { envelope = Envelope { mail_from: String::new(), + origin_ip: String::new(), rcpt_to: Vec::new(), data: Vec::new(), }; diff --git a/filtermail/src/utils.rs b/filtermail/src/utils.rs index 3b221480..230886e4 100644 --- a/filtermail/src/utils.rs +++ b/filtermail/src/utils.rs @@ -1,4 +1,5 @@ use mailparse::MailAddr; +use std::str::FromStr; /// Extracts the first email address found in SMTP command or email header. /// @@ -26,14 +27,51 @@ pub fn extract_address(input: &str) -> Option { }) } -pub fn get_domain_from_address(address: &str) -> Option { - let parts: Vec<&str> = address.split('@').collect(); - if parts.len() == 2 - && let Some(domain) = parts.get(1) - { - Some(domain.to_string()) - } else { - None +/// Domain part of an email address, either a domain-literal (IP address in square brackets with +/// optional protocol prefix) or a regular domain name. +#[derive(Debug, PartialEq, Eq)] +pub enum AddressDomain { + /// Domain literal, e.g. + /// - `192.0.2.0` in `test@[192.0.2.0]`, + /// - `2001:db8::1` in `test@[IPv6:2001:db8::1]`. + Literal(String), + /// Regular domain name, e.g. `example.org` in `test@example.org`. + Name(String), +} + +impl FromStr for AddressDomain { + type Err = crate::error::Error; + + /// Extracts the domain part from an email address and returns it as an [`AddressDomain`]. + /// + /// Returns an [`Error`] if `address` is not a valid email address. + /// + /// [`Error`]: crate::error::Error + fn from_str(address: &str) -> Result { + let parts: Vec<&str> = address.split('@').collect(); + if parts.len() == 2 + && let Some(domain) = parts.get(1) + { + // domain literals + if domain.starts_with('[') && domain.ends_with(']') { + let mut address_trimmed = domain + .get(1..domain.len() - 1) + .unwrap_or(domain) + .to_lowercase(); + + address_trimmed = address_trimmed + .strip_prefix("ipv6:") + .unwrap_or(&address_trimmed) + .to_string(); + + return Ok(AddressDomain::Literal(address_trimmed.to_string())); + } + Ok(AddressDomain::Name(domain.to_string())) + } else { + Err(crate::error::Error::InvalidEmailAddress( + address.to_string(), + )) + } } } @@ -51,18 +89,26 @@ mod tests { #[case("mail from:", Some("t4@example.org".to_string()))] #[case("Foo Bar ", Some("t5@example.org".to_string()))] #[case("t6@example.org", Some("t6@example.org".to_string()))] + #[case("t7@[192.0.2.0]", Some("t7@[192.0.2.0]".to_string()))] + #[case("", Some("t7@[192.0.2.0]".to_string()))] + // This is a bug in mailparse, it refuses to parse IPv6 without "<>" around. + // https://github.com/staktrace/mailparse/issues/137 + #[case("t8@[IPv6:2001:db8::1]", None)] + #[case("", Some("t8@[ipv6:2001:db8::1]".to_string()))] fn test_extract_address(#[case] input: &str, #[case] expected: Option) { let result = extract_address(input); assert_eq!(result, expected) } #[rstest] - #[case("t1@example.org", Some("example.org".to_string()))] - #[case("SRS1=HHH=example.com==HHH=TT=example.org=alice@example.net", Some("example.net".to_string()))] + #[case("t1@example.org", Some(AddressDomain::Name("example.org".to_string())))] + #[case("SRS1=HHH=example.com==HHH=TT=example.org=alice@example.net", Some(AddressDomain::Name("example.net".to_string())))] + #[case("t7@[192.0.2.0]", Some(AddressDomain::Literal("192.0.2.0".to_string())))] + #[case("t8@[IPv6:2001:db8::1]", Some(AddressDomain::Literal("2001:db8::1".to_string())))] #[case("invalid", None)] #[case("invalid@address@com", None)] - fn test_get_domain_from_address(#[case] input: &str, #[case] expected: Option) { - let result = get_domain_from_address(input); + fn test_get_domain_from_address(#[case] input: &str, #[case] expected: Option) { + let result = AddressDomain::from_str(input).ok(); assert_eq!(result, expected); } } From 4eb7e9fd633d89c2d8417ea639b1f539a5fc91ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:38:31 +0100 Subject: [PATCH 045/166] chore(release): prepare for 0.4.0 (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 6 ++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index c63387e0..3dcfd734 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.4.0 - 2026-02-17 + +### Features + +- [**breaking**] DKIM verifier (#35) +- Support addresses using domain literals (#42) ## 0.3.0 - 2026-02-14 ### Features diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index fa4ccd03..8b8f0b95 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -382,7 +382,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.3.0" +version = "0.4.0" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index d533da06..d84f2d2f 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.3.0" +version = "0.4.0" edition = "2024" license = "MIT" readme = "README.md" From 6e2a37956cb15f0a73e4a3321e52ccbe8d51b7cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:00:31 +0100 Subject: [PATCH 046/166] chore(tests): Add a way to disable DKIM for tests (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/src/inbound.rs | 8 ++++++-- filtermail/src/main.rs | 11 ++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index f849f0a1..a1d5b879 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -15,13 +15,15 @@ use std::str::FromStr; pub struct IncomingBeforeQueueHandler { config: Config, dkim_verifier: DkimVerifier, + skip_dkim: bool, } impl IncomingBeforeQueueHandler { - pub fn new(config: Config) -> Result { + pub fn new(config: Config, skip_dkim: bool) -> Result { Ok(Self { config, dkim_verifier: DkimVerifier::new()?, + skip_dkim, }) } } @@ -67,7 +69,9 @@ impl SmtpHandler for IncomingBeforeQueueHandler { } } AddressDomain::Name(domain) => { - self.dkim_verifier.verify(&envelope.data, &domain).await?; + if !self.skip_dkim { + self.dkim_verifier.verify(&envelope.data, &domain).await?; + } } } diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index ac30f2dc..8d5899f6 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -96,9 +96,18 @@ async fn main() { process::exit(1); } } else { + // Skip DKIM verification (used for tests). + let skip_dkim = env::var("FILTERMAIL_SKIP_DKIM") + .map(|val| val == "1" || val.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + if skip_dkim { + log::warn!("DKIM verification DISABLED! This should not be used in production."); + } + let handler = Arc::new( // We want to panic here if the handler cannot be created. - IncomingBeforeQueueHandler::new(config.clone()).unwrap(), + IncomingBeforeQueueHandler::new(config.clone(), skip_dkim).unwrap(), ); let addr = format!("127.0.0.1:{}", config.filtermail_smtp_port_incoming); let max_size = config.max_message_size; From 083ec1a60bf0f9954dbee03562528edcc9a07671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:07:46 +0100 Subject: [PATCH 047/166] chore(release): prepare for 0.4.1 (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 5 +++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index 3dcfd734..80ad9619 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.4.1 - 2026-02-17 + +### Miscellaneous Tasks + +- *(tests)* Add a way to disable DKIM for tests (#50) ## 0.4.0 - 2026-02-17 ### Features diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 8b8f0b95..d4abf1ef 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -382,7 +382,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.4.0" +version = "0.4.1" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index d84f2d2f..7226a015 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.4.0" +version = "0.4.1" edition = "2024" license = "MIT" readme = "README.md" From 7d4fac698f485a59bfbf01784bf52496587114fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:33:40 +0100 Subject: [PATCH 048/166] fix(dkim): Make simple header canonicalization work properly (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously header parsing caused leading whitespaces to be trimmed in HeaderBody, which resulted in incorrect simple header canonicalization. Signed-off-by: Jagoda Ślązak --- filtermail/src/dkim_verifier.rs | 120 +++++++++++++++++------- filtermail/test_data/asm.eml | 112 +++++++++++----------- filtermail/test_data/dkim-abjadiyah.eml | 47 ++++++++++ 3 files changed, 191 insertions(+), 88 deletions(-) create mode 100644 filtermail/test_data/dkim-abjadiyah.eml diff --git a/filtermail/src/dkim_verifier.rs b/filtermail/src/dkim_verifier.rs index 5ec7e18d..f61b9746 100644 --- a/filtermail/src/dkim_verifier.rs +++ b/filtermail/src/dkim_verifier.rs @@ -116,17 +116,67 @@ impl LookupTxt for CachedResolver { } } +/// Dummy resolver that always returns the same TXT record, for testing purposes. +#[derive(Clone)] +struct MockResolver(String); + +impl LookupTxt for MockResolver { + type Answer = Box>>>; + type Query<'a> = Pin> + Send + 'a>>; + + fn lookup_txt(&self, _domain: &str) -> Self::Query<'_> { + Box::pin(async move { + let txts: Self::Answer = Box::new(std::iter::once(Ok(self.0.clone().into_bytes()))); + Ok(txts) + }) + } +} + +/// Either a real resolver or a mock. +#[allow(clippy::large_enum_variant)] +#[derive(Clone)] +enum Resolver { + /// A [`CachedResolver`] + Real(CachedResolver), + /// A [`MockResolver`] + Mock(MockResolver), +} + +impl LookupTxt for Resolver { + type Answer = Box>>>; + type Query<'a> = Pin> + Send + 'a>>; + + fn lookup_txt(&self, domain: &str) -> Self::Query<'_> { + match self { + Resolver::Real(resolver) => resolver.lookup_txt(domain), + Resolver::Mock(resolver) => resolver.lookup_txt(domain), + } + } +} + +impl From for Resolver { + fn from(value: CachedResolver) -> Self { + Resolver::Real(value) + } +} + +impl From for Resolver { + fn from(value: MockResolver) -> Self { + Resolver::Mock(value) + } +} + /// DKIM verifier using a pre-configured [`viadkim`] verifier, a [`CachedResolver`] for DNS lookups, /// and strict domain name alignment check. pub struct DkimVerifier { - resolver: CachedResolver, + resolver: Resolver, config: viadkim::Config, } impl DkimVerifier { /// Creates a new [`DkimVerifier`] with the provided resolver. pub fn new() -> Result { - let resolver = CachedResolver::new()?; + let resolver = CachedResolver::new()?.into(); let config = viadkim::Config { lookup_timeout: Duration::from_secs(60), ..Default::default() @@ -134,44 +184,34 @@ impl DkimVerifier { Ok(Self { resolver, config }) } + /// Creates a new [`DkimVerifier`] with a mock resolver that always returns the provided TXT record. + #[cfg(test)] + fn mock(txt: String) -> Self { + let resolver = MockResolver(txt).into(); + let config = viadkim::Config { + lookup_timeout: Duration::from_secs(60), + ..Default::default() + }; + Self { resolver, config } + } + /// Verifies the DKIM signature of a raw email message and its alignment with the provided /// domain. pub async fn verify(&self, raw_mail: &[u8], from_domain: &str) -> Result<(), String> { - let (headers, body_start) = { - use viadkim::{FieldBody, FieldName, HeaderField}; + let mail_data = String::from_utf8_lossy(raw_mail); + let (header, body) = mail_data + .split_once("\r\n\r\n") + .ok_or("554 Malformed data")?; - let Ok((headers, body_start)) = mailparse::parse_headers(raw_mail) else { - return Err("500 Failed to parse message headers".to_string()); - }; - - let mut viadkim_headers: Vec = Vec::new(); - for header in headers { - match ( - FieldName::new(header.get_key()), - FieldBody::new(header.get_value_raw()), - ) { - (Ok(name), Ok(body)) => viadkim_headers.push((name, body)), - (Err(e), _) | (_, Err(e)) => { - log::debug!("Failed to parse header {header:?}, skipping: {e}"); - } - } - } - - let viadkim_headers = viadkim::HeaderFields::new(viadkim_headers).map_err(|e| { - log::error!("Failed to parse headers for DKIM verification: {e}"); - "500 Failed to parse message headers".to_string() - })?; - - (viadkim_headers, body_start) - }; + let header = header.parse().map_err(|_| "554 Malformed header")?; let Some(mut verifier) = - viadkim::Verifier::verify_header(&self.resolver, &headers, &self.config).await + viadkim::Verifier::verify_header(&self.resolver, &header, &self.config).await else { return Err("554 5.7.1 No DKIM signature found".to_string()); }; - 'hasher: for chunk in raw_mail.get(body_start..).unwrap_or_default().chunks(8192) { + 'hasher: for chunk in body.as_bytes().chunks(8192) { if verifier.process_body_chunk(chunk) == BodyHasherStance::Done { break 'hasher; } @@ -189,8 +229,10 @@ impl DkimVerifier { log::debug!("Signature {}: Verification failed, skipping", res.index); // We only invalidate cache on actual validation error, and not alignment error. // TODO: ideally we should retry without cache and swap cached value only on success. - self.resolver - .invalidate_cache(signature.selector.as_ref(), signature.domain.as_ref()); + if let Resolver::Real(resolver) = &self.resolver { + resolver + .invalidate_cache(signature.selector.as_ref(), signature.domain.as_ref()); + } continue; } @@ -212,3 +254,17 @@ impl DkimVerifier { Err("554 5.7.1 No valid DKIM signature found".to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_dkim_verifier() { + let verifier = DkimVerifier::mock( + r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5krC4Xi5Wkr6eMlla38LCFmV645E3FLAgsRl2YJ0SrZ4N2Vw1/yH0mefvtk7HYE7ytV7RQl/er2CkSsaHLJSYLmPCBw5CO6PSsBSXuh6DBqdylh/1t9vVQ9p38fTwn9gU1QvplcpRQL9eepRra1k24VMIaVy2ZZcu3LI9zkPsR7o7TyNaeMhsL8ouWInWc1NSid+p0SgliQuwHIejZhlTPE60JLbJE0OR9I4wmq3377H6z/QrO8XeabCgtmTuzE/hTRyIyNS40jql/99pjlhIcjM2U+P2B0FjwYt7BwLHsgANr74ctlnKY+SdH25rNwVpPmkotaULG5SJCByKBkfCwIDAQAB;s=email;t=s"#.to_string() + ); + let raw_mail = include_bytes!("../test_data/dkim-abjadiyah.eml"); + verifier.verify(raw_mail, "abjadiyah.xyz").await.unwrap(); + } +} diff --git a/filtermail/test_data/asm.eml b/filtermail/test_data/asm.eml index d95bb683..372d0041 100644 --- a/filtermail/test_data/asm.eml +++ b/filtermail/test_data/asm.eml @@ -1,56 +1,56 @@ -From: one@example.org -To: two@example.org -Autocrypt-Setup-Message: v1 -Subject: Autocrypt Setup Message -Date: Tue, 22 Jan 2019 12:56:29 +0100 -Content-type: multipart/mixed; boundary="Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ" - ---Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ -Content-Type: text/plain - -This message contains all information to transfer your Autocrypt -settings along with your secret key securely from your original -device. - -To set up your new device for Autocrypt, please follow the -instuctions that should be presented by your new device. - -You can keep this message and use it as a backup for your secret -key. If you want to do this, you should write down the Setup Code -and store it securely. ---Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ -Content-Type: application/autocrypt-setup -Content-Disposition: attachment; filename="autocrypt-setup-message.html" - - -

-This is the Autocrypt setup file used to transfer settings and -keys between clients. You can decrypt it using the Setup Code -presented on your old device, and then import the contained key -into your keyring. -

- -
------BEGIN PGP MESSAGE-----
-Passphrase-Format: numeric9x4
-Passphrase-Begin: 17
-
-jA0EBwMCFAxADoCdzeX/0ukBlqI5+pfpKb751qd/7nLNbkpy3gVcaf1QwRPZYt40
-Ynp08UqRQ2g48ZlnzHLSwlTGOPTuv2Jt8ka+pgZ45xzvJSG2gau03xP4VsC271kR
-VmCjdb0Y6Rk96mAwfGzrkbaRQ9Z7fIoL866GOv6h9neiVIkp+JYlTV6ISD0ZQJ4Q
-I6dOQkB/TWZyVjtiJDOQHdfNWliA6NtqaLq19wlu9L5xXjuNpY95KwR8EJXWe0+o
-Y3d2U/KxOAkXKghP2Qg1GtlPVeGC5T4p03TGI6pzKT+kHX6Rrm9wK6sM9aTquMmF
-Vok84Jg1DFnwivWC2RILR81rXi7k/+Y6MUbveFgJ9cQduqpxnmD7TjOblYu7M6zp
-YGAUxh8DRKlIMn2QsA++DBYQ6ACZvwuY8qTDLkqPDo4WqM313dsMJbyGjDdVE7EM
-PESS+RlABETpZXz8g/ycr6DIUNdlbPcmYlsBfHWDOuR2GFFTwmlv5slWS39dJv38
-E0eIe1CwdxI801Se7t7dUUS/ZF8wb6GlmxOcqGbF8eko1Z0S64IAm7/h13MRQCxI
-geQnHfGYVJ2FOimoCMEKwfa9x++RFTDW0u7spDC2uWvK/1viV8OfRppFhLr/kmKb
-18lWXuAz80DAjUDUsVqEq2MvJBJGoCJUEyjuRsLkHYRM5jYk4v50LyyR0Om73nWF
-nZBqmqNzdr7Xb9PHHdFhnEc0VvoYbrcM0RVYcEMW3YbmejM891j1d6Iv+/n/qND/
-NdebGrfWJMmFLf/iEkzTZ3/v5inW9LpWoRc94ioCjJTaEo8Rib6ARRFaJVIsmNXi
-YicFGO98D+zX+a2t9Yz6IpPajVslnOp6ScpmXgts/2XWD7oE+JgxSAqo/dLVsHgP
-Ufo=
-=pulM
------END PGP MESSAGE-----
-
---Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ-- +From: one@example.org +To: two@example.org +Autocrypt-Setup-Message: v1 +Subject: Autocrypt Setup Message +Date: Tue, 22 Jan 2019 12:56:29 +0100 +Content-type: multipart/mixed; boundary="Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ" + +--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ +Content-Type: text/plain + +This message contains all information to transfer your Autocrypt +settings along with your secret key securely from your original +device. + +To set up your new device for Autocrypt, please follow the +instuctions that should be presented by your new device. + +You can keep this message and use it as a backup for your secret +key. If you want to do this, you should write down the Setup Code +and store it securely. +--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ +Content-Type: application/autocrypt-setup +Content-Disposition: attachment; filename="autocrypt-setup-message.html" + + +

+This is the Autocrypt setup file used to transfer settings and +keys between clients. You can decrypt it using the Setup Code +presented on your old device, and then import the contained key +into your keyring. +

+ +
+-----BEGIN PGP MESSAGE-----
+Passphrase-Format: numeric9x4
+Passphrase-Begin: 17
+
+jA0EBwMCFAxADoCdzeX/0ukBlqI5+pfpKb751qd/7nLNbkpy3gVcaf1QwRPZYt40
+Ynp08UqRQ2g48ZlnzHLSwlTGOPTuv2Jt8ka+pgZ45xzvJSG2gau03xP4VsC271kR
+VmCjdb0Y6Rk96mAwfGzrkbaRQ9Z7fIoL866GOv6h9neiVIkp+JYlTV6ISD0ZQJ4Q
+I6dOQkB/TWZyVjtiJDOQHdfNWliA6NtqaLq19wlu9L5xXjuNpY95KwR8EJXWe0+o
+Y3d2U/KxOAkXKghP2Qg1GtlPVeGC5T4p03TGI6pzKT+kHX6Rrm9wK6sM9aTquMmF
+Vok84Jg1DFnwivWC2RILR81rXi7k/+Y6MUbveFgJ9cQduqpxnmD7TjOblYu7M6zp
+YGAUxh8DRKlIMn2QsA++DBYQ6ACZvwuY8qTDLkqPDo4WqM313dsMJbyGjDdVE7EM
+PESS+RlABETpZXz8g/ycr6DIUNdlbPcmYlsBfHWDOuR2GFFTwmlv5slWS39dJv38
+E0eIe1CwdxI801Se7t7dUUS/ZF8wb6GlmxOcqGbF8eko1Z0S64IAm7/h13MRQCxI
+geQnHfGYVJ2FOimoCMEKwfa9x++RFTDW0u7spDC2uWvK/1viV8OfRppFhLr/kmKb
+18lWXuAz80DAjUDUsVqEq2MvJBJGoCJUEyjuRsLkHYRM5jYk4v50LyyR0Om73nWF
+nZBqmqNzdr7Xb9PHHdFhnEc0VvoYbrcM0RVYcEMW3YbmejM891j1d6Iv+/n/qND/
+NdebGrfWJMmFLf/iEkzTZ3/v5inW9LpWoRc94ioCjJTaEo8Rib6ARRFaJVIsmNXi
+YicFGO98D+zX+a2t9Yz6IpPajVslnOp6ScpmXgts/2XWD7oE+JgxSAqo/dLVsHgP
+Ufo=
+=pulM
+-----END PGP MESSAGE-----
+
+--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ-- diff --git a/filtermail/test_data/dkim-abjadiyah.eml b/filtermail/test_data/dkim-abjadiyah.eml new file mode 100644 index 00000000..17c5e03e --- /dev/null +++ b/filtermail/test_data/dkim-abjadiyah.eml @@ -0,0 +1,47 @@ +DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=abjadiyah.xyz; + s=default; t=1771356412; + bh=EVfAHeUMDygbJe0SkMWJHjgXGjtiTLZnMQbyWqzsrCY=; + h=From:To:Subject:Date:From; + b=3GHd4aCp7sjNR7RfHzLUUQ0PLk4FM8WLlOO5d8BXvpa6oCjERP0vsP545EDo4lBFC + xg9dlSrhIwEWsrJDXSFStA7VU1WmWBVTzKoN8bZk5w/2HJf7BcB1BG2SQ0tyu2iEtl + sv21HCXLw1IAMd2IiZNPQRlk0PgrKDqFmgULbktIWkc917GNAWhJaVCQhk+YF6cfql + AKP3i8NPK53JY6vsMBO3YuYb/DcRGyvhUUJGofgG2+NwESA20ayI85EPiUzbzGpx0z + r5IsLwiMApO5W0svfzgU52VhtkAqVyi38ZvjYsCJlKXB885SRsHsojCCSIznvFSmNw + pWF4qKBvNrQpQ== +From: +To: "hidden-recipients": ; +Subject: [...] +Date: Sat, 14 Feb 2026 21:13:38 +0000 +X-Last-TLS-Session-Version: TLSv1.3 +X-Spamd-Result: default: False [5.18 / 15.00]; + MISSING_MID(2.50)[]; + FORGED_RECIPIENTS(2.00)[m:,s:link2xt@testrun.org]; + DATE_IN_PAST(1.00)[70]; + DMARC_POLICY_ALLOW(-0.50)[abjadiyah.xyz,reject]; + R_MISSING_CHARSET(0.50)[]; + R_DKIM_ALLOW(-0.20)[abjadiyah.xyz:s=default]; + R_SPF_ALLOW(-0.20)[+mx]; + MIME_GOOD(-0.10)[text/plain]; + FISHY_TLD(0.10)[abjadiyah.xyz]; + ONCE_RECEIVED(0.10)[]; + IP_REPUTATION_HAM(-0.01)[asn: 29670(0.00), country: DE(-0.01), ip: 2001:67c:1400:21d0::(0.00)]; + MX_GOOD(-0.01)[]; + MISSING_XM_UA(0.00)[]; + RCPT_COUNT_ONE(0.00)[1]; + BCC(0.00)[]; + ARC_NA(0.00)[]; + MIME_TRACE(0.00)[0:+]; + DWL_DNSWL_BLOCKED(0.00)[abjadiyah.xyz:dkim]; + DKIM_TRACE(0.00)[abjadiyah.xyz:+]; + RCPT_MAILCOW_DOMAIN(0.00)[testrun.org]; + SINGLE_SHORT_PART(0.00)[]; + ARC_SIGNED(0.00)[testrun.org:s=dkim:i=1]; + RCVD_COUNT_ZERO(0.00)[0]; + TO_DN_ALL(0.00)[]; + ASN(0.00)[asn:29670, ipnet:2001:67c:1400::/45, country:DE]; + FROM_NO_DN(0.00)[]; + FROM_EQ_ENVFROM(0.00)[] +X-Rspamd-Queue-Id: E471A4105C + +Hello! + From 94f1b299170554db741e2a45ec45bbae7aced063 Mon Sep 17 00:00:00 2001 From: l Date: Thu, 19 Feb 2026 18:06:08 +0000 Subject: [PATCH 049/166] refactor: do not copy the mail in memory for DKIM verification (#54) `String::from_utf8_lossy` always copies the data for the case when it needs to replace invalid UTF-8, but we don't want invalid UTF-8 anyway as replacing invalid UTF-8 characters will break DKIM signature. --- filtermail/src/dkim_verifier.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/src/dkim_verifier.rs b/filtermail/src/dkim_verifier.rs index f61b9746..d7fd03a6 100644 --- a/filtermail/src/dkim_verifier.rs +++ b/filtermail/src/dkim_verifier.rs @@ -198,7 +198,7 @@ impl DkimVerifier { /// Verifies the DKIM signature of a raw email message and its alignment with the provided /// domain. pub async fn verify(&self, raw_mail: &[u8], from_domain: &str) -> Result<(), String> { - let mail_data = String::from_utf8_lossy(raw_mail); + let mail_data = str::from_utf8(raw_mail).or(Err("554 Non-UTF-8 message"))?; let (header, body) = mail_data .split_once("\r\n\r\n") .ok_or("554 Malformed data")?; From 54982b1fdd40c5920f6b27f6994dde81daecd27e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:38:46 +0100 Subject: [PATCH 050/166] feat: Save rejected messages to `/tmp` (#55) --- filtermail/src/inbound.rs | 16 +++++++++++++--- filtermail/src/utils.rs | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index a1d5b879..28d0f51f 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -6,7 +6,7 @@ use crate::dkim_verifier::DkimVerifier; use crate::message::{check_encrypted, is_securejoin}; pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; -use crate::utils::{AddressDomain, extract_address}; +use crate::utils::{AddressDomain, extract_address, log_eml}; use async_trait::async_trait; use mailparse::{MailHeaderMap, parse_mail}; use std::str::FromStr; @@ -69,8 +69,18 @@ impl SmtpHandler for IncomingBeforeQueueHandler { } } AddressDomain::Name(domain) => { - if !self.skip_dkim { - self.dkim_verifier.verify(&envelope.data, &domain).await?; + if !self.skip_dkim + && let Err(e) = self.dkim_verifier.verify(&envelope.data, &domain).await + { + let eml_path = log_eml("dkim-verify", &envelope.data) + .await + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|e| { + log::error!("Failed to save rejected message to file: {e}"); + "ERR".to_string() + }); + log::info!("Rejected message stored at: {eml_path}"); + return Err(e); } } } diff --git a/filtermail/src/utils.rs b/filtermail/src/utils.rs index 230886e4..d9a0adfa 100644 --- a/filtermail/src/utils.rs +++ b/filtermail/src/utils.rs @@ -1,4 +1,5 @@ use mailparse::MailAddr; +use std::path::PathBuf; use std::str::FromStr; /// Extracts the first email address found in SMTP command or email header. @@ -75,6 +76,23 @@ impl FromStr for AddressDomain { } } +/// Logs email to `/tmp/filtermail-rejected//.eml` +/// and returns the file path. +/// +/// Returns [`crate::error::Error`] on IO error. +pub async fn log_eml(reason: &str, data: &[u8]) -> Result { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let filename = format!("{timestamp}.eml"); + let mut path = PathBuf::from(format!("/tmp/filtermail-rejected/{reason}")); + tokio::fs::create_dir_all(&path).await?; + path.push(filename); + tokio::fs::write(&path, data).await?; + Ok(path) +} + #[cfg(test)] mod tests { use super::*; From 288afa3273030661a4934ff47c127add0b4244be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:12:35 +0100 Subject: [PATCH 051/166] chore(release): prepare for 0.5.0 (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 13 +++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index 80ad9619..4f2dad5d 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,16 @@ +## 0.5.0 - 2026-02-20 + +### Bug Fixes + +- *(dkim)* Make simple header canonicalization work properly (#53) + +### Features + +- Save rejected messages to `/tmp` (#55) + +### Refactor + +- Do not copy the mail in memory for DKIM verification (#54) ## 0.4.1 - 2026-02-17 ### Miscellaneous Tasks diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index d4abf1ef..25ac681e 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -382,7 +382,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.4.1" +version = "0.5.0" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 7226a015..cffa28d1 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.4.1" +version = "0.5.0" edition = "2024" license = "MIT" readme = "README.md" From 6145be410869442ee0c6a6fbca3ebbc734904a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:44:30 +0100 Subject: [PATCH 052/166] fix(dkim): Accept TXT records with no `v=` tag (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache at most 3 TXT records instead of trying to find one that includes `DKIM`. Closes #59 Signed-off-by: Jagoda Ślązak --- filtermail/src/dkim_verifier.rs | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/filtermail/src/dkim_verifier.rs b/filtermail/src/dkim_verifier.rs index d7fd03a6..00452667 100644 --- a/filtermail/src/dkim_verifier.rs +++ b/filtermail/src/dkim_verifier.rs @@ -21,7 +21,7 @@ struct CachedResolver { // Note: Arc is required despite we are holding the whole handler in an Arc, // because viadkim will internally clone the resolver (LookupTxt + Clone + 'static) // to parallelize lookups in case of multiple signatures... - cache: Arc>>>, + cache: Arc>>>>, } impl CachedResolver { @@ -76,41 +76,39 @@ impl LookupTxt for CachedResolver { { let mut cache = self.cache.lock(); - if let Some(rdata) = cache.get(&name) { - let txts: Self::Answer = Box::new(std::iter::once(Ok(rdata.clone()))); - log::debug!("Using cached RDATA for {}", name); + if let Some(txts) = cache.get(&name) { + let txts: Self::Answer = Box::new(txts.clone().into_iter().map(Ok)); + log::debug!("Using cached TXT records for {}", name); return Ok(txts); } } - log::debug!("Trying to resolve TXT for {}", name); - let txt = { + log::debug!("Trying to resolve TXT records for {}", name); + let txts: Vec> = { let lookup = self.dns_resolver.txt_lookup(name.clone()).await?; // viadkim would filter out non-DKIM TXT records, // but we filter it here anyway so that we know which one should be cached. lookup .into_iter() - .find_map(|txt| { + // We don't check all records, as this can be a DoS attack vector. + // In theory, selector domains should only have a single TXT record. + // In practice, we check at most 3, just in case of weird configuration. + .take(3) + .map(|txt| { let rdata_raw = txt.txt_data().concat(); let rdata = String::from_utf8_lossy(&rdata_raw); - // naive check, but this can't be an attack vector, - // and selector domain should only have a single TXT record anyway. - if rdata.contains("DKIM") { - Some(Self::normalize_rdata(&rdata).into_bytes()) - } else { - None - } + Self::normalize_rdata(&rdata).into_bytes() }) - .ok_or(io::ErrorKind::NotFound)? + .collect() }; { let mut cache = self.cache.lock(); - cache.put(name, txt.clone()); + cache.put(name, txts.clone()); } - let txts: Self::Answer = Box::new(std::iter::once(Ok(txt))); + let txts: Self::Answer = Box::new(txts.into_iter().map(Ok)); Ok(txts) }) } From 38627bfb4bc5e305869b2bf89f4433aeecbef765 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:45:01 +0100 Subject: [PATCH 053/166] chore(deps): Bump taiki-e/install-action from 2.67.30 to 2.68.5 (#57) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.67.30 to 2.68.5. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/288875dd3d64326724fa6d9593062d9f8ba0b131...71b48393496777ee11188c07a34d48b048a985cd) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.68.5 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 56f2e578..984406a7 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - - uses: taiki-e/install-action@288875dd3d64326724fa6d9593062d9f8ba0b131 # v2 + - uses: taiki-e/install-action@71b48393496777ee11188c07a34d48b048a985cd # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check From 8a1684d48823397309417ac0b35618cab178e175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:45:21 +0100 Subject: [PATCH 054/166] fix(smtp): Properly handle bounce messages (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #58 Signed-off-by: Jagoda Ślązak --- filtermail/src/smtp_server.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index 7efcccf2..ddb3d9fb 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -116,6 +116,11 @@ where .write_all(b"250-filtermail\r\n250-XFORWARD ADDR\r\n250 OK\r\n") .await?; writer.flush().await?; + } else if cmd.to_uppercase().starts_with("MAIL FROM:<>") { + // bounce message + envelope.mail_from = String::new(); + writer.write_all(b"250 OK\r\n").await?; + writer.flush().await?; } else if cmd.to_uppercase().starts_with("MAIL FROM:") { if let Some(from) = extract_address(cmd) { match handler.handle_mail(&from) { From 52208c42b62973b874bb2d4132cce9bb14e57969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 24 Feb 2026 08:12:17 +0100 Subject: [PATCH 055/166] fix(dkim): Accept TXT records with escaped quotes (#61) --- filtermail/src/dkim_verifier.rs | 57 ++++++++++++------ filtermail/test_data/dkim-privitty.eml | 80 ++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 filtermail/test_data/dkim-privitty.eml diff --git a/filtermail/src/dkim_verifier.rs b/filtermail/src/dkim_verifier.rs index 00452667..9ce00958 100644 --- a/filtermail/src/dkim_verifier.rs +++ b/filtermail/src/dkim_verifier.rs @@ -14,6 +14,18 @@ use viadkim::verifier::LookupTxt; // "top 1000 relays" is much more than enough, the limit is mostly to prevent DoS attacks. const LRU_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).expect("1000 != 0"); +/// Normalizes a TXT record RDATA by removing irrelevant characters. +/// +/// Some DKIM key records use e.g. LF + WSP line breaks. +/// This is technically not correct, and `viadkim` fails to parse such records, +/// but in practice this is accepted by many implementations. +/// +/// Additionally, removes escaped quotes, as such records as: +/// `"...UL9" "\" \"7vGm..."` proved to still be accepted by e.g. dkimpy or OpenDKIM. +fn normalize_rdata(txt_data: &str) -> String { + txt_data.replace([' ', '\t', '\n', '\r', '"'], "") +} + /// DNS resolver for DKIM TXT records, that caches RDATA in memory. #[derive(Clone)] struct CachedResolver { @@ -38,15 +50,6 @@ impl CachedResolver { }) } - /// Normalizes a TXT record RDATA by removing whitespace characters. - /// - /// Some DKIM key records use e.g. LF + WSP line breaks. - /// This is technically not correct, and `viadkim` fails to parse such records, - /// but in practice this is accepted by many implementations. - fn normalize_rdata(txt_data: &str) -> String { - txt_data.replace([' ', '\t', '\n', '\r'], "") - } - /// Invalidates the cached RDATA for a given selector and domain. /// /// Fails silently. @@ -98,7 +101,10 @@ impl LookupTxt for CachedResolver { .map(|txt| { let rdata_raw = txt.txt_data().concat(); let rdata = String::from_utf8_lossy(&rdata_raw); - Self::normalize_rdata(&rdata).into_bytes() + log::trace!("TXT (concat): {:?}", rdata); + let normalized = normalize_rdata(&rdata); + log::trace!("TXT (normalized): {:?}", normalized); + normalized.into_bytes() }) .collect() }; @@ -124,7 +130,8 @@ impl LookupTxt for MockResolver { fn lookup_txt(&self, _domain: &str) -> Self::Query<'_> { Box::pin(async move { - let txts: Self::Answer = Box::new(std::iter::once(Ok(self.0.clone().into_bytes()))); + let txts: Self::Answer = + Box::new(std::iter::once(Ok(normalize_rdata(&self.0).into_bytes()))); Ok(txts) }) } @@ -256,13 +263,29 @@ impl DkimVerifier { #[cfg(test)] mod tests { use super::*; + use rstest::rstest; + #[rstest] #[tokio::test] - async fn test_dkim_verifier() { - let verifier = DkimVerifier::mock( - r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5krC4Xi5Wkr6eMlla38LCFmV645E3FLAgsRl2YJ0SrZ4N2Vw1/yH0mefvtk7HYE7ytV7RQl/er2CkSsaHLJSYLmPCBw5CO6PSsBSXuh6DBqdylh/1t9vVQ9p38fTwn9gU1QvplcpRQL9eepRra1k24VMIaVy2ZZcu3LI9zkPsR7o7TyNaeMhsL8ouWInWc1NSid+p0SgliQuwHIejZhlTPE60JLbJE0OR9I4wmq3377H6z/QrO8XeabCgtmTuzE/hTRyIyNS40jql/99pjlhIcjM2U+P2B0FjwYt7BwLHsgANr74ctlnKY+SdH25rNwVpPmkotaULG5SJCByKBkfCwIDAQAB;s=email;t=s"#.to_string() - ); - let raw_mail = include_bytes!("../test_data/dkim-abjadiyah.eml"); - verifier.verify(raw_mail, "abjadiyah.xyz").await.unwrap(); + #[case::simple_simple_canonicalization( + r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5krC4Xi5Wkr6eMlla38LCFmV645E3FLAgsRl2YJ0SrZ4N2Vw1/yH0mefvtk7HYE7ytV7RQl/er2CkSsaHLJSYLmPCBw5CO6PSsBSXuh6DBqdylh/1t9vVQ9p38fTwn9gU1QvplcpRQL9eepRra1k24VMIaVy2ZZcu3LI9zkPsR7o7TyNaeMhsL8ouWInWc1NSid+p0SgliQuwHIejZhlTPE60JLbJE0OR9I4wmq3377H6z/QrO8XeabCgtmTuzE/hTRyIyNS40jql/99pjlhIcjM2U+P2B0FjwYt7BwLHsgANr74ctlnKY+SdH25rNwVpPmkotaULG5SJCByKBkfCwIDAQAB;s=email;t=s"#, + include_bytes!("../test_data/dkim-abjadiyah.eml") + )] + #[case::txt_escaped_quotes( + r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1giTh8KDkEchWhrAB6hGnb+V87kTezkt5I3SP7BGNg8wpv0yAuj/SUmnsttYmcEU+zmNAPqxePmCNvmjLYi/c3YyWEBwHcLyZE9OlS9W4enPdsoCuEN3DayzN4JCV3MsXMedCORvLFXmIARDXDLJUSJeqCeQoudXa9GmF1CrCmx70YyTtV0xOIxEzo7z0DkUL9" "7vGmNJCv6EMpi9wccMKKu8NSmOv+DBw1MLIJqChSZMCs8CYZ5i0KT/+Lijtn6B7wyOcAuQsVL+zr7DWYrFdrePe0wGuivfJ3SvUEfUo1SIykl0nvm0iLGhjNmNa1e/tUw4ULXhQ12Qw685+sq7wIDAQAB;s=email;t=s"#, + include_bytes!("../test_data/dkim-privitty.eml") + )] + async fn test_dkim_verifier(#[case] txt: &str, #[case] message: &[u8]) { + let verifier = DkimVerifier::mock(txt.to_string()); + verifier.verify(message, "abjadiyah.xyz").await.unwrap(); + } + + #[rstest] + #[case::escaped_quotes( + r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1giTh8KDkEchWhrAB6hGnb+V87kTezkt5I3SP7BGNg8wpv0yAuj/SUmnsttYmcEU+zmNAPqxePmCNvmjLYi/c3YyWEBwHcLyZE9OlS9W4enPdsoCuEN3DayzN4JCV3MsXMedCORvLFXmIARDXDLJUSJeqCeQoudXa9GmF1CrCmx70YyTtV0xOIxEzo7z0DkUL9" "7vGmNJCv6EMpi9wccMKKu8NSmOv+DBw1MLIJqChSZMCs8CYZ5i0KT/+Lijtn6B7wyOcAuQsVL+zr7DWYrFdrePe0wGuivfJ3SvUEfUo1SIykl0nvm0iLGhjNmNa1e/tUw4ULXhQ12Qw685+sq7wIDAQAB;s=email;t=s"#, + r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1giTh8KDkEchWhrAB6hGnb+V87kTezkt5I3SP7BGNg8wpv0yAuj/SUmnsttYmcEU+zmNAPqxePmCNvmjLYi/c3YyWEBwHcLyZE9OlS9W4enPdsoCuEN3DayzN4JCV3MsXMedCORvLFXmIARDXDLJUSJeqCeQoudXa9GmF1CrCmx70YyTtV0xOIxEzo7z0DkUL97vGmNJCv6EMpi9wccMKKu8NSmOv+DBw1MLIJqChSZMCs8CYZ5i0KT/+Lijtn6B7wyOcAuQsVL+zr7DWYrFdrePe0wGuivfJ3SvUEfUo1SIykl0nvm0iLGhjNmNa1e/tUw4ULXhQ12Qw685+sq7wIDAQAB;s=email;t=s"# + )] + fn test_normalize_rdata(#[case] input: &str, #[case] expected: &str) { + assert_eq!(normalize_rdata(input), expected); } } diff --git a/filtermail/test_data/dkim-privitty.eml b/filtermail/test_data/dkim-privitty.eml new file mode 100644 index 00000000..c23274cd --- /dev/null +++ b/filtermail/test_data/dkim-privitty.eml @@ -0,0 +1,80 @@ +Received: from chat.privittytech.com (unknown [35.154.144.0]) + by nine.testrun.org (Postfix) with ESMTPS + for ; Sat, 21 Feb 2026 22:17:25 +0100 (CET) +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; + d=chat.privittytech.com; s=opendkim; t=1771708644; + bh=Qs0Xoe6dH8cM3t3hv45QSkQQe5TLvyJ5R4OGJL9GYUc=; + h=Content-Type:From:To:Subject:Date:References:from:reply-to: + subject:date:to:cc:resent-date:resent-from:resent-sender:resent-to: + resent-cc:in-reply-to:references:list-id:list-help: + list-unsubscribe:list-subscribe:list-post:list-owner:list-archive: + autocrypt; + b=rsdW71iU79CF0dooc1PnbZsJbWZsnBampamyF3jTWirF8AXBBKeR2JJsxr4km3Vx9 + QoT1s7VAyl/hdUBh2tBWT7Kzvw4KJcgkZ7tV/uRcY7edlLoH8TqATeTkWeNX18rL92 + F6Vv/6krs/z8Rpz2ZaVkBdLQif0fLQ3e96ODOdpgkLFXWsMVTM5rGepPWYht8yA+BF + /sMopzgHhJaBvDqV42Ep75IK4w3opf9fPTAvtpwKbnDmJQmLSDv1So/Rd4xL3Oj6PX + CSO8dfJSzQ+ukyoDqVMddVCUpgVgFHTXMdxxQVTe54tj2JPZan5ql90SHa3lNFTgN8 + hrM8BExznme5Q== +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="18965f9fc8f94f5b_d3ca8c34f6c5d05_4df177c93ae6931" +MIME-Version: 1.0 +From: <8dn75miab@chat.privittytech.com> +To: "hidden-recipients": ; +Subject: [...] +Date: Mon, 16 Feb 2026 06:28:47 +0000 +Message-ID: <0737aaa0-d6f9-4240-9d94-1b73216ca96d@localhost> +References: <0737aaa0-d6f9-4240-9d94-1b73216ca96d@localhost> +Chat-Version: 1.0 + + +--18965f9fc8f94f5b_d3ca8c34f6c5d05_4df177c93ae6931 +Content-Type: application/pgp-encrypted; charset="utf-8" +Content-Description: PGP/MIME version identification +Content-Transfer-Encoding: 7bit + +Version: 1 + +--18965f9fc8f94f5b_d3ca8c34f6c5d05_4df177c93ae6931 +Content-Type: application/octet-stream; name="encrypted.asc"; + charset="utf-8" +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc"; +Content-Transfer-Encoding: 7bit + +-----BEGIN PGP MESSAGE----- + +wU4DWvqoogTPq7gSAQdAO8msB6rJLzr5isiWx/ZoAHqNnYZTcE4fpjpj8C1+Vhgg +c3o2P2p9T2GfJ6aa6KqbJQW7VPl8zaaylYCN8JQLKqHBTgPP/P3FoTw8jxIBB0CE +/iffxyAThaUNNQyp0vmrW9k8v47ll3TBQjCv6K/bbiB7u1vsYbBfl8FQgjDNhHTV +U+lJQntsqmsn3VmxRjz1vdLD4QGvqJXYHUUyYJ7EetMBHFcmpCLlO14MhvZKw4Gd +yWhEcgv6fsFpBaTk7Y9MXrflbpSR84aCtNqEKWU6FjjFWoRUzWVV42gI0cXectg6 +Ry2p21EfiBsLLaQdMneep7Hpq/GXccLZcFVVc9VCQq80lq/9GkiDSdA/9JD3gGFu +NCDMKkgAQRaKVkfGZ7VhQqpkAZeeI3H/kGjzFElcNUqTH1lRgi7OgJuhIA9VeIWb +ZX677i3tjnNWrAJoIVs+l5I7jWhgE9VMutI4kBA0fxk2QYW++n9TPz2/FbTuFkjZ +ULXQC8F7CaVdUVb1DIAc7hw8EGFX98LKj04ucBuoe1QfJK6LKbhelSmGYGQbBOOG +AZSPs/jgH1EkgtIzxD6rTJTFy/+2Y4eOTJaq0F4ZP32+pOVr3Iq2OuSS3LakfCjk +TPqe9Y1w4q0afQdS2tHlnLGOGrFxoYeRjUnTeeHuH9sSp6G6Lf5751sLVgYUtILo +Kgaac+gCh0rBK4Ve4LksnY6FUzogLYxIt4NkmDIWDvlDc0RnC+moQ/VaICPYlnMl +dJB4mVFKefqqQM61rWTZyqHc8tDJj0D1dqpxkYJT+VH+t0RNp6br8I+wQ91BP595 +Usma3Uv1MjLufMFWcTtd72PAfk13uWF2u2dkKO+KR4MUnXm/B3ZSBAUfGfBaH4SP +mn/dDeNY0c9ya+8l+Nt2lK/3imh3E29z22pnVwTB/PAK1HCxrehw6j/W6cfrylXC +TcqLJW2K542oy5Q6zE5sU6mYRfOQcoR/s3t7y5ynu3s29kIbUhysPN2qT7YMmTWo +hSlMHU5oDrzDNKWkE1FsXpTCGTdSvKhQf/HuzmQBXiCQxLlH+55UJPwbf2H95o1A +cVChQP6vwqBIrB0k1CkJbfqCMPvr7fyNcZNqdk+iyXwPu9vOhE+4k5ybCZ7t/uoY +DHm05/nj5jqIEG2vV0sTogd8dnu6KiVK+wET4mZwOMmAPp/8ROOmZ2NvEK1iPlgL +c92u2XLAgZW+AbI93oJARWI8MGT5CfvJcKvjO1Nr4KIjN2G4uMiE//f2hPM676mq +eeFqFgITGN5ivfwnfdRgEHB63mkNeTVpzrHLNYYH/HVPdjsTqDp0cPCvZxoIDgTj +/9+uJtfgRekpD0LqOfnUg27TBthFksuFaiMW4cvxgNdqOVbo7RjcSvGg2pd80Bu0 +0gv/47tDGl0Hn9BI+XIbwLRos6dSzxgU6XotjfA31lcSFiP99IkWH4sBFRWVIaEX +iPqJOmDWsgWjzzgu9JYQ8cZH4BP74Rv8qCC2klP4HfdQuErekNu7vDPLH3iHWpuX +HcSc6imuNXz45ncwyEsQ+tP4t42SEv0dGRaMpwNIniIiKYAL+dMFNvdqEsKiPRoG +f7xEuY2sZKoBtNw+gXcGlYs+QhSXJtpAdpHg+Bp0AYUQBU9ciYFfk8ZsEbSdpWGP +D0+Xd7bWrgUYN0UM0EdtpbyxnH/bFe3Jg5jdDuS43ifhYTEPEdpmG5pnqNk5Yi0Q +wxN6ntEHbfy3gmhMePOigh09RlwgbdoqurlvlUL2FHEY/Z1P9VI0tHbTXayUcBQk +3aljbg== +=wRZT +-----END PGP MESSAGE----- + + +--18965f9fc8f94f5b_d3ca8c34f6c5d05_4df177c93ae6931-- + From e1841cb59008bd6c51b294125d6c10afeec2cf81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:58:28 +0100 Subject: [PATCH 056/166] fix(logs): Log `From` address instead of envelope `MAIL FROM`. (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #64 Signed-off-by: Jagoda Ślązak --- filtermail/src/inbound.rs | 6 +++--- filtermail/src/outbound.rs | 14 +++++--------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 28d0f51f..80233dfd 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -35,8 +35,6 @@ impl SmtpHandler for IncomingBeforeQueueHandler { } async fn check_data(&self, envelope: &Envelope) -> Result<(), String> { - log::debug!("Processing DATA message from {}", envelope.mail_from); - let message = match parse_mail(&envelope.data) { Ok(m) => m, Err(e) => return Err(format!("500 Failed to parse message: {}", e)), @@ -53,6 +51,8 @@ impl SmtpHandler for IncomingBeforeQueueHandler { return Err(format!("500 Invalid FROM header: {from_header}")); }; + log::debug!("Processing DATA message from {from_addr}"); + let from_domain = AddressDomain::from_str(&from_addr).map_err(|e| e.smtp_response())?; match from_domain { @@ -108,7 +108,7 @@ impl SmtpHandler for IncomingBeforeQueueHandler { for recipient in &envelope.rcpt_to { if !self.config.is_cleartext_ok(recipient) { - log::warn!("Rejected unencrypted mail from: {}", envelope.mail_from); + log::warn!("Rejected unencrypted mail from: {from_addr}"); return Err(ENCRYPTION_NEEDED_523.to_string()); } } diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index a6f4edbf..86cfe7b3 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -56,8 +56,6 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { } async fn check_data(&self, envelope: &Envelope) -> Result<(), String> { - log::debug!("Processing DATA message from {}", envelope.mail_from); - let message = match parse_mail(&envelope.data) { Ok(m) => m, Err(e) => return Err(format!("500 Failed to parse message: {}", e)), @@ -75,6 +73,8 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { let from_addr = extract_address(&from_header) .ok_or(format!("500 Invalid FROM header: {from_header}"))?; + log::debug!("Processing DATA message from {from_addr}"); + if !envelope.mail_from.eq_ignore_ascii_case(&from_addr) { return Err(format!( "500 Invalid FROM <{}> for <{}>", @@ -91,18 +91,14 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { log::info!("Outgoing: Filtering unencrypted mail."); // Allow passthrough senders - if self - .config - .passthrough_senders - .contains(&envelope.mail_from) - { + if self.config.passthrough_senders.contains(&from_addr) { return Ok(()); } // Allow self-sent Autocrypt Setup Message if envelope.rcpt_to.len() == 1 && let Some(rcpt_to) = envelope.rcpt_to.first() - && *rcpt_to == envelope.mail_from + && *rcpt_to == from_addr { let subject = message .headers @@ -115,7 +111,7 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { for recipient in &envelope.rcpt_to { if !recipient_matches_passthrough(recipient, &self.config.passthrough_recipients) { - log::warn!("Rejected unencrypted mail from: {}", envelope.mail_from); + log::warn!("Rejected unencrypted mail from: {from_addr}"); return Err(ENCRYPTION_NEEDED_523.to_string()); } } From ff3ffd54efb44173024da777d1417476c3466f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:04:43 +0100 Subject: [PATCH 057/166] chore(release): prepare for 0.5.1 (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 8 ++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index 4f2dad5d..b4d132d3 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.5.1 - 2026-02-24 + +### Bug Fixes + +- *(dkim)* Accept TXT records with no `v=` tag (#62) +- *(smtp)* Properly handle bounce messages (#63) +- *(dkim)* Accept TXT records with escaped quotes (#61) +- *(logs)* Log `From` address instead of envelope `MAIL FROM`. (#66) ## 0.5.0 - 2026-02-20 ### Bug Fixes diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 25ac681e..b6b2cbb6 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -382,7 +382,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.5.0" +version = "0.5.1" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index cffa28d1..3b5ffb06 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.5.0" +version = "0.5.1" edition = "2024" license = "MIT" readme = "README.md" From 2e1c38466c5c72f7e96d6be662f861301b14f3f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:08:41 +0100 Subject: [PATCH 058/166] fix(logs): Log correct address for outbound messages (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #68 Signed-off-by: Jagoda Ślązak --- filtermail/src/outbound.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 86cfe7b3..a654fe52 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -73,7 +73,10 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { let from_addr = extract_address(&from_header) .ok_or(format!("500 Invalid FROM header: {from_header}"))?; - log::debug!("Processing DATA message from {from_addr}"); + // MAIL FROM is our source of truth for outbound messages, + // as this address is checked by postfix against the username before sending it + // to filtermail. + log::debug!("Processing DATA message from {}", envelope.mail_from); if !envelope.mail_from.eq_ignore_ascii_case(&from_addr) { return Err(format!( From a5c6880ceb135efd89050cad785b96acda01fa59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:10:01 +0100 Subject: [PATCH 059/166] refactor: Check if email is encrypted before verifying DKIM (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This way we won't be logging DKIM rejections on messages that would be rejected anyway due to not being encrypted. Fixes #69 Signed-off-by: Jagoda Ślązak --- filtermail/src/inbound.rs | 66 +++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 80233dfd..90d91ff2 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -26,34 +26,11 @@ impl IncomingBeforeQueueHandler { skip_dkim, }) } -} -#[async_trait] -impl SmtpHandler for IncomingBeforeQueueHandler { - fn handle_mail(&self, _address: &str) -> Result<(), String> { - Ok(()) - } - - async fn check_data(&self, envelope: &Envelope) -> Result<(), String> { - let message = match parse_mail(&envelope.data) { - Ok(m) => m, - Err(e) => return Err(format!("500 Failed to parse message: {}", e)), - }; - - let from_header = message - .headers - .get_first_value("From") - .unwrap_or_default() - .trim() - .to_string(); - - let Some(from_addr) = extract_address(&from_header) else { - return Err(format!("500 Invalid FROM header: {from_header}")); - }; - - log::debug!("Processing DATA message from {from_addr}"); - - let from_domain = AddressDomain::from_str(&from_addr).map_err(|e| e.smtp_response())?; + /// Verify the origin of the email either by performing DKIM verification or by matching the FROM + /// domain literal with the origin IP. + async fn verify_origin(&self, envelope: &Envelope, from_addr: &str) -> Result<(), String> { + let from_domain = AddressDomain::from_str(from_addr).map_err(|e| e.smtp_response())?; match from_domain { AddressDomain::Literal(ip) => { @@ -85,6 +62,35 @@ impl SmtpHandler for IncomingBeforeQueueHandler { } } + Ok(()) + } +} + +#[async_trait] +impl SmtpHandler for IncomingBeforeQueueHandler { + fn handle_mail(&self, _address: &str) -> Result<(), String> { + Ok(()) + } + + async fn check_data(&self, envelope: &Envelope) -> Result<(), String> { + let message = match parse_mail(&envelope.data) { + Ok(m) => m, + Err(e) => return Err(format!("500 Failed to parse message: {}", e)), + }; + + let from_header = message + .headers + .get_first_value("From") + .unwrap_or_default() + .trim() + .to_string(); + + let Some(from_addr) = extract_address(&from_header) else { + return Err(format!("500 Invalid FROM header: {from_header}")); + }; + + log::debug!("Processing DATA message from {from_addr}"); + let mail_encrypted = check_encrypted(&message, false); log::debug!("mail_encrypted: {mail_encrypted}"); log::debug!("is_securejoin: {}", is_securejoin(&message)); @@ -92,7 +98,7 @@ impl SmtpHandler for IncomingBeforeQueueHandler { // Allow encrypted or securejoin messages if mail_encrypted || is_securejoin(&message) { log::info!("Incoming: Filtering encrypted mail."); - return Ok(()); + return self.verify_origin(envelope, &from_addr).await; } log::info!("Incoming: Filtering unencrypted mail."); @@ -103,7 +109,7 @@ impl SmtpHandler for IncomingBeforeQueueHandler { && from_addr.to_lowercase().starts_with("mailer-daemon@") && message.ctype.mimetype == "multipart/report" { - return Ok(()); + return self.verify_origin(envelope, &from_addr).await; } for recipient in &envelope.rcpt_to { @@ -113,7 +119,7 @@ impl SmtpHandler for IncomingBeforeQueueHandler { } } - Ok(()) + self.verify_origin(envelope, &from_addr).await } async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { From 82618de311b706c16992b4740ae2d6eeb325d698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:20:08 +0100 Subject: [PATCH 060/166] feat: Check incoming email return address (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the MAIL FROM doesn't match the From header, we do not reject the mail, as this can be caused by e.g. SRS forwarding. Instead, we reset the envelope address, so it is reinjected as `MAIL FROM:<>` to prevent sending a bounce message. Closes #67 Signed-off-by: Jagoda Ślązak --- filtermail/src/inbound.rs | 11 ++++++++++- filtermail/src/outbound.rs | 2 +- filtermail/src/smtp_server.rs | 8 +++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 90d91ff2..7b4c28cd 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -72,7 +72,7 @@ impl SmtpHandler for IncomingBeforeQueueHandler { Ok(()) } - async fn check_data(&self, envelope: &Envelope) -> Result<(), String> { + async fn check_data(&self, envelope: &mut Envelope) -> Result<(), String> { let message = match parse_mail(&envelope.data) { Ok(m) => m, Err(e) => return Err(format!("500 Failed to parse message: {}", e)), @@ -91,6 +91,15 @@ impl SmtpHandler for IncomingBeforeQueueHandler { log::debug!("Processing DATA message from {from_addr}"); + if !envelope.mail_from.eq_ignore_ascii_case(&from_addr) { + // If the MAIL FROM doesn't match the From header, we do not reject the mail, + // as this can be caused by e.g. SRS forwarding. + // Instead, we reset the envelope address, so it is reinjected as + // `MAIL FROM:<>` to prevent sending a bounce message. + // + envelope.mail_from = String::new(); + } + let mail_encrypted = check_encrypted(&message, false); log::debug!("mail_encrypted: {mail_encrypted}"); log::debug!("is_securejoin: {}", is_securejoin(&message)); diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index a654fe52..6614da66 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -55,7 +55,7 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { Ok(()) } - async fn check_data(&self, envelope: &Envelope) -> Result<(), String> { + async fn check_data(&self, envelope: &mut Envelope) -> Result<(), String> { let message = match parse_mail(&envelope.data) { Ok(m) => m, Err(e) => return Err(format!("500 Failed to parse message: {}", e)), diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index ddb3d9fb..e3411590 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -22,13 +22,15 @@ pub trait SmtpHandler: Send + Sync { fn handle_mail(&self, address: &str) -> Result<(), String>; /// Checks the DATA command before reinjection. - async fn check_data(&self, envelope: &Envelope) -> Result<(), String>; + /// + /// Can optionally modify the envelope before reinjection. + async fn check_data(&self, envelope: &mut Envelope) -> Result<(), String>; /// Reinjects the mail back to postfix. async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String>; /// Handles the DATA command. - async fn handle_data(&self, envelope: &Envelope) -> Result { + async fn handle_data(&self, envelope: &mut Envelope) -> Result { log::debug!("handle_DATA before-queue"); self.check_data(envelope).await?; self.reinject_mail(envelope).await.map_err(|e| { @@ -182,7 +184,7 @@ where envelope.data = data; // Process the message - match handler.handle_data(&envelope).await { + match handler.handle_data(&mut envelope).await { Ok(response) => { log::debug!("Sent: {response}"); writer From e62b161848a7e65414777a461b598191a1e445fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:24:58 +0100 Subject: [PATCH 061/166] chore: Bump cargo-dist (#73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/dist-workspace.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/dist-workspace.toml b/filtermail/dist-workspace.toml index d3e27f44..5d485a27 100644 --- a/filtermail/dist-workspace.toml +++ b/filtermail/dist-workspace.toml @@ -4,7 +4,7 @@ members = ["cargo:."] # Config for 'dist' [dist] # The preferred dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.30.3" +cargo-dist-version = "0.30.4" # The installers to generate for each app installers = [] # Target platforms to build apps for (Rust target-triple syntax) From 6bb90f881117ade8fa2130dd262fdd271da44aeb Mon Sep 17 00:00:00 2001 From: Jagoda Estera ?l?zak <128227338+j-g00da@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:12:34 +0100 Subject: [PATCH 062/166] chore(ci): Build and upload binaries in CI (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use zigbuild directly instead of cargo-dist, as cargo-dist doesn't suit our needs well (e.g. uploading binaries at top level). Signed-off-by: Jagoda Ślązak --- filtermail/.github/workflows/release.yml | 66 ++++++++++++++++++++++++ filtermail/Cargo.toml | 4 +- filtermail/dist-workspace.toml | 11 ---- 3 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 filtermail/.github/workflows/release.yml delete mode 100644 filtermail/dist-workspace.toml diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml new file mode 100644 index 00000000..f0428b2b --- /dev/null +++ b/filtermail/.github/workflows/release.yml @@ -0,0 +1,66 @@ +on: + release: + types: [published] + +jobs: + build: + name: Build binaries. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master + with: + toolchain: stable + targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl + - uses: taiki-e/install-action@71b48393496777ee11188c07a34d48b048a985cd # v2 + with: + tool: cargo-zigbuild + - name: Build + run: | + RUSTFLAGS="-Ctarget-feature=+crt-static -Clink-self-contained=yes" \ + cargo zigbuild \ + --release \ + --target x86_64-unknown-linux-musl \ + --target aarch64-unknown-linux-musl + - name: Upload x86_64 binary + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: filtermail-x86_64 + path: target/x86_64-unknown-linux-musl/release/filtermail + if-no-files-found: error + - name: Upload aarch64 binary + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: filtermail-aarch64 + path: target/aarch64-unknown-linux-musl/release/filtermail + if-no-files-found: error + + publish: + name: Upload binaries to the release + needs: ["build"] + runs-on: ubuntu-latest + steps: + - name: Download aarch64 binary + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: filtermail-x86_64 + path: filtermail-x86_64 + - name: Download x86_64 binary + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: filtermail-aarch64 + path: filtermail-aarch64 + - name: Upload binaries to the GitHub release + if: github.event_name == 'release' + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + REF_NAME: ${{ github.ref_name }} + run: | + mkdir dist + mv filtermail-x86_64 dist/filtermail-x86_64 + mv filtermail-aarch64 dist/filtermail-aarch64 + gh release upload "$REF_NAME" \ + --repo ${{ github.repository }} \ + dist/* diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 3b5ffb06..d782a24f 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -28,7 +28,5 @@ parking_lot = "0.12.5" rstest = "0.26.1" testresult = "0.4.1" -# The profile that 'dist' will build with -[profile.dist] -inherits = "release" +[profile.release] lto = "thin" diff --git a/filtermail/dist-workspace.toml b/filtermail/dist-workspace.toml deleted file mode 100644 index 5d485a27..00000000 --- a/filtermail/dist-workspace.toml +++ /dev/null @@ -1,11 +0,0 @@ -[workspace] -members = ["cargo:."] - -# Config for 'dist' -[dist] -# The preferred dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.30.4" -# The installers to generate for each app -installers = [] -# Target platforms to build apps for (Rust target-triple syntax) -targets = ["aarch64-unknown-linux-musl", "x86_64-unknown-linux-musl"] From edca7cbf2a27db288ee3734e9bd363b98a981a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:46:16 +0100 Subject: [PATCH 063/166] chore(ci): Add missing Zig dependency (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/.github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index f0428b2b..d836e77c 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -14,6 +14,7 @@ jobs: with: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - uses: taiki-e/install-action@71b48393496777ee11188c07a34d48b048a985cd # v2 with: tool: cargo-zigbuild From 3054b31dc7cbcf50695b2ca02d586114858b9968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:02:11 +0100 Subject: [PATCH 064/166] chore(release): prepare for 0.5.2 (#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 19 +++++++++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index b4d132d3..36ba62ed 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,22 @@ +## 0.5.2 - 2026-02-27 + +### Bug Fixes + +- *(logs)* Log correct address for outbound messages (#70) + +### Features + +- Check incoming email return address (#72) + +### Miscellaneous Tasks + +- Bump cargo-dist (#73) +- *(ci)* Build and upload binaries in CI (#76) +- *(ci)* Add missing Zig dependency (#77) + +### Refactor + +- Check if email is encrypted before verifying DKIM (#71) ## 0.5.1 - 2026-02-24 ### Bug Fixes diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index b6b2cbb6..cd2566c2 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -382,7 +382,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.5.1" +version = "0.5.2" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index d782a24f..a3da9c96 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.5.1" +version = "0.5.2" edition = "2024" license = "MIT" readme = "README.md" From f14979777511ca2fd20a5433baefc48a9a4a7a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:30:47 +0100 Subject: [PATCH 065/166] chore(ci): Fix binary publish job (#79) --- filtermail/.github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index d836e77c..f79bf70a 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -60,8 +60,8 @@ jobs: REF_NAME: ${{ github.ref_name }} run: | mkdir dist - mv filtermail-x86_64 dist/filtermail-x86_64 - mv filtermail-aarch64 dist/filtermail-aarch64 + mv filtermail-x86_64/filtermail dist/filtermail-x86_64 + mv filtermail-aarch64/filtermail dist/filtermail-aarch64 gh release upload "$REF_NAME" \ --repo ${{ github.repository }} \ dist/* From 954d950c2fe3f76965bd04c1e9e89a8041c316a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:47:50 +0100 Subject: [PATCH 066/166] chore(deps): Bump actions/upload-artifact from 6.0.0 to 7.0.0 (#80) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index f79bf70a..57deea04 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -26,13 +26,13 @@ jobs: --target x86_64-unknown-linux-musl \ --target aarch64-unknown-linux-musl - name: Upload x86_64 binary - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: filtermail-x86_64 path: target/x86_64-unknown-linux-musl/release/filtermail if-no-files-found: error - name: Upload aarch64 binary - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: filtermail-aarch64 path: target/aarch64-unknown-linux-musl/release/filtermail From 962de095c0a70985355c6f165912ce8bec1042bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:48:30 +0100 Subject: [PATCH 067/166] chore(deps): Bump taiki-e/install-action from 2.68.5 to 2.68.13 (#82) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.68.5 to 2.68.13. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/71b48393496777ee11188c07a34d48b048a985cd...a3324fb0eb94b8230ec968c3389c1b7929fc2f3b) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.68.13 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 984406a7..d0dab442 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - - uses: taiki-e/install-action@71b48393496777ee11188c07a34d48b048a985cd # v2 + - uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index 57deea04..a2e78e04 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@71b48393496777ee11188c07a34d48b048a985cd # v2 + - uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2 with: tool: cargo-zigbuild - name: Build From 3a571b7aa72b5835410e90392ea111b165010c42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:50:01 +0100 Subject: [PATCH 068/166] chore(deps): Bump actions/download-artifact from 7.0.0 to 8.0.0 (#81) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7.0.0 to 8.0.0. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/37930b1c2abaa49bbe596cd826c3c89aef350131...70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 8.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index a2e78e04..87d35fca 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -44,12 +44,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Download aarch64 binary - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: filtermail-x86_64 path: filtermail-x86_64 - name: Download x86_64 binary - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: filtermail-aarch64 path: filtermail-aarch64 From c088cbfe4de6fc3e8d3936be30ec105e427bc959 Mon Sep 17 00:00:00 2001 From: Andrey 0xdc09 <50486086+DarkCat09@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:17:37 +0400 Subject: [PATCH 069/166] refactor: use enum for mode cli arg (#83) --- filtermail/src/main.rs | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index 8d5899f6..1992f23c 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -46,6 +46,24 @@ use std::sync::Arc; const ENCRYPTION_NEEDED_523: &str = "523 Encryption Needed: Invalid Unencrypted Mail"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + Outgoing, + Incoming, +} + +impl std::str::FromStr for Mode { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "outgoing" => Ok(Mode::Outgoing), + "incoming" => Ok(Mode::Incoming), + _ => Err("Error: mode must be 'incoming' or 'outgoing'"), + } + } +} + #[tokio::main] async fn main() { // default to info level @@ -72,10 +90,13 @@ async fn main() { unreachable!("args length checked above") }; - if mode != "incoming" && mode != "outgoing" { - eprintln!("Error: mode must be 'incoming' or 'outgoing'"); - process::exit(1); - } + let mode = match mode.parse::() { + Ok(mode) => mode, + Err(e) => { + eprintln!("{e}"); + process::exit(1); + } + }; let config = match Config::from_file(config_path) { Ok(c) => c, @@ -85,7 +106,7 @@ async fn main() { } }; - if mode == "outgoing" { + if mode == Mode::Outgoing { let handler = Arc::new(OutgoingBeforeQueueHandler::new(config.clone())); let addr = format!("127.0.0.1:{}", config.filtermail_smtp_port); let max_size = config.max_message_size; From a8033b107eb89154302c7dd62075496e3c634dad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:53:51 +0100 Subject: [PATCH 070/166] chore(deps): Bump taiki-e/install-action from 2.68.13 to 2.68.20 (#87) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.68.13 to 2.68.20. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/a3324fb0eb94b8230ec968c3389c1b7929fc2f3b...4a136ceac97fe29b942ff3b6c749a46c7950d853) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.68.20 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index d0dab442..9d2d8980 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - - uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2 + - uses: taiki-e/install-action@4a136ceac97fe29b942ff3b6c749a46c7950d853 # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index 87d35fca..dd436304 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2 + - uses: taiki-e/install-action@4a136ceac97fe29b942ff3b6c749a46c7950d853 # v2 with: tool: cargo-zigbuild - name: Build From 07496abab419ffaa83e77ebcbd40f340ec7200ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:56:32 +0100 Subject: [PATCH 071/166] chore(deps): Bump tokio from 1.49.0 to 1.50.0 (#86) Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.49.0 to 1.50.0. - [Release notes](https://github.com/tokio-rs/tokio/releases) - [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.49.0...tokio-1.50.0) --- updated-dependencies: - dependency-name: tokio dependency-version: 1.50.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/Cargo.lock | 4 ++-- filtermail/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index cd2566c2..d9dc030e 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -1557,9 +1557,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index a3da9c96..23d7cf7e 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -13,7 +13,7 @@ async-trait = "0.1.89" base64 = "0.22.1" serde = { version = "1.0.228", features = ["derive"] } serini = "0.2.2" -tokio = { version = "1.49.0", features = ["full"] } +tokio = { version = "1.50.0", features = ["full"] } thiserror = "2.0.18" mailparse = "0.16.1" log = "0.4.29" From 0d7f5d7b0549ed600251826aea54aa673a0742a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Wed, 11 Mar 2026 18:04:14 +0100 Subject: [PATCH 072/166] docs(readme): Fix license link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/README.md b/filtermail/README.md index e7eeac39..06bae092 100644 --- a/filtermail/README.md +++ b/filtermail/README.md @@ -12,4 +12,4 @@ filtermail (incoming|outgoing) Code licensed under [MIT](LICENSE). -Binaries distributed under [GPL-3.0-or-later](LICENSE). +Binaries distributed under [GPL-3.0-or-later](LICENSE-GPL). From 779e7ca2b4d9eac5c4b6531875b7f61bac585d4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:00:56 +0100 Subject: [PATCH 073/166] docs(readme): Clarify licensing of the binaries Co-authored-by: holger krekel --- filtermail/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/filtermail/README.md b/filtermail/README.md index 06bae092..5a30e70f 100644 --- a/filtermail/README.md +++ b/filtermail/README.md @@ -12,4 +12,5 @@ filtermail (incoming|outgoing) Code licensed under [MIT](LICENSE). -Binaries distributed under [GPL-3.0-or-later](LICENSE-GPL). +Binary releases of `filtermail` link with `viadkim` +and are thus subject to the [GPL-3.0-or-later](LICENSE-GPL). From 82a9850a4b60dfa4fab3d38b7fab4471fc1e66d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:15:08 +0100 Subject: [PATCH 074/166] docs(readme): Improve README.md (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #85 Signed-off-by: Jagoda Ślązak --- filtermail/README.md | 114 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/filtermail/README.md b/filtermail/README.md index 5a30e70f..00e4392b 100644 --- a/filtermail/README.md +++ b/filtermail/README.md @@ -1,12 +1,124 @@ # filtermail -Rust drop-in reimplementation of chatmaild's filtermail. +A postfix smtpd proxy filter used by [chatmail relay](https://github.com/chatmail/relay). + +Filtermail is a fast, minimal and secure Rust-based SMTP before-queue filter. +By acting as a protocol-aware proxy for incoming and outgoing messages, +it enforces mandatory end-to-end encryption, +performs DKIM verification, +and handles per-sender rate limiting. ## Usage ```plain filtermail (incoming|outgoing) ``` +where `` is a path to `chatmail.ini` configuration file. + +Filtermail can be used in `incoming` or `outgoing` mode that apply different settings +to filter either incoming or outgoing emails. + +### Incoming mode + +Filtermail in incoming mode performs following steps: + +1. Rejects messages if `DATA` exceeds configured message size limit. +2. Rejects messages that do not meet at least one of the following criteria: + - PGP encrypted, + - securejoin message, + - mailer-daemon message, + - all recipients allow cleartext + (`enforceE2EEincoming` is not present in their mailbox directory). +3. If `MAIL FROM` doesn't match `From` header, +the address is removed from `MAIL FROM` on reinjection +(prevents bounces to possibly spoofed `MAIL FROM`). +4. Checks message origin, +depending on address type: + - **domain** - performs a strict DKIM verification and domain alignment check + (domain of address from `From` header must exactly match the DKIM signature domain), + rejecting messages that fail. + - **domain-literal (IP address)** - rejects message if the IP in domain-literal of `From` header address + does not match the origin IP received by `XFORWARD` command. +5. In case of a DKIM failure, +the message is saved to `/tmp/filtermail-rejected/dkim-verify` directory for later inspection. + +### Outgoing mode + +Filtermail in outgoing mode performs following steps: + +1. Rejects messages at `MAIL FROM` stage if the address exceeded rate limit. +2. Rejects messages if `DATA` exceeds configured message size limit. +3. Rejects messages which `From` header address does not match one in `MAIL FROM`. +4. Rejects messages that do not meet at least one of the following criteria: + - PGP encrypted, + - securejoin message, + - sender is in `passthrough_senders`, + - self-sent Autocrypt Setup Message, + - all recipients match `passthrough_recipients`. + +## Configuration + +### chatmail.ini + +Filtermail shares the same configuration file as chatmail relay, +but implements a custom parser that only requires a small subset of configuration options: + +- `filtermail_smtp_port` - port to listen on in outgoing mode, +defaults to `10080`. +- `filtermail_smtp_port_incoming` - port to listen on in incoming mode, +defaults to `10081`. +- `postfix_reinject_port` - port to reinject messages to postfix in outgoing mode, +defaults to `10025`. +- `postfix_reinject_port_incoming` - port to reinject messages to postfix in incoming mode, +defaults to `10026`. +- `max_message_size` - maximum allowed message size in bytes, +defaults to `31457280` (30 MiB). +- `max_user_send_per_minute` - email sending rate per user and minute, +defaults to `60`. +- `max_user_send_burst_size` - per-user max burst size for sending rate limiting (GCRA bucket capacity), +defaults to `10`. +- `passthrough_senders` - space separated list of email addresses +which can send outbound un-encrypted mail. +- `passthrough_recipients` - space separated list of email addresses +which can receive inbound un-encrypted mail, +item may start with `@` to whitelist whole recipient domains. +- `mail_domain` - domain name used in email addresses. +- `mailboxes_dir` - path to mailboxes directory, +defaults to `/home/vmail/mail/`. + +### Environment variables + +Additional options that can be set using environment variables: + +- `RUST_LOG` - set log level, +defaults to `info`. +- `FILTERMAIL_SKIP_DKIM` - completely skip DKIM verification; +only for testing purposes and not recommended for production use, +defaults to `0`. + +## Usage outside of chatmail relay + +**Filtermail development is focused on supporting it as a systemd service used by chatmail relay.** +Althrough unsupported, it may still work outside of this context or even without postfix, +with few considerations: + +- Filtermail expects to receive messages from a trusted server, +and thus should not be exposed directly to the internet. +- In incoming mode it expects to receive `XFORWARD` commands with the origin IP, +if the other server doesn't support this, +it will lead to rejection of every email using domain-literals in it's `From` header address. +- Issues outside of chatmail relay context are not necessarily considered bugs; +PRs fixing them are not guaranteed to be accepted. +(Trivial changes may still be considered, +please open an issue to discuss any such changes before working on them). + + +## Releases + +Filtermail is distributed as a statically linked linux binary, +available for `x86_64` and `aarch64` architectures. + +Binaries are available on the [releases page](https://github.com/chatmail/filtermail/releases). ## License From 8dd9436a0df0b09e5513004a07a880b2a038baf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:25:45 +0100 Subject: [PATCH 075/166] feat!: Remove IP verification for domain-literals (#90) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: messages using domain-literal addresses don't require to match the origin SMTP connection IP anymore. Signed-off-by: Jagoda Ślązak --- filtermail/README.md | 6 +-- filtermail/src/config.rs | 22 +++++++++ filtermail/src/inbound.rs | 51 ++++++++++++++----- filtermail/test_data/encrypted-ipv4.eml | 66 +++++++++++++++++++++++++ filtermail/test_data/encrypted-ipv6.eml | 66 +++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 18 deletions(-) create mode 100644 filtermail/test_data/encrypted-ipv4.eml create mode 100644 filtermail/test_data/encrypted-ipv6.eml diff --git a/filtermail/README.md b/filtermail/README.md index 00e4392b..bf29732a 100644 --- a/filtermail/README.md +++ b/filtermail/README.md @@ -37,8 +37,7 @@ depending on address type: - **domain** - performs a strict DKIM verification and domain alignment check (domain of address from `From` header must exactly match the DKIM signature domain), rejecting messages that fail. - - **domain-literal (IP address)** - rejects message if the IP in domain-literal of `From` header address - does not match the origin IP received by `XFORWARD` command. + - **domain-literal (IP address)** - currently no-op. 5. In case of a DKIM failure, the message is saved to `/tmp/filtermail-rejected/dkim-verify` directory for later inspection. @@ -104,9 +103,6 @@ with few considerations: - Filtermail expects to receive messages from a trusted server, and thus should not be exposed directly to the internet. -- In incoming mode it expects to receive `XFORWARD` commands with the origin IP, -if the other server doesn't support this, -it will lead to rejection of every email using domain-literals in it's `From` header address. - Issues outside of chatmail relay context are not necessarily considered bugs; PRs fixing them are not guaranteed to be accepted. (Trivial changes may still be considered, diff --git a/filtermail/src/config.rs b/filtermail/src/config.rs index f6f52909..f399f9ec 100644 --- a/filtermail/src/config.rs +++ b/filtermail/src/config.rs @@ -104,3 +104,25 @@ impl Config { NonZeroU32::new(10).expect("10 != 0") } } + +#[cfg(test)] +impl Default for Config { + /// Creates a default configuration with example.org domain. + /// + /// Used for tests. + fn default() -> Self { + Self { + filtermail_smtp_port: Self::default_filtermail_smtp_port(), + filtermail_smtp_port_incoming: Self::default_filtermail_smtp_port_incoming(), + postfix_reinject_port: Self::default_postfix_reinject_port(), + postfix_reinject_port_incoming: Self::default_postfix_reinject_port_incoming(), + max_message_size: Self::default_max_message_size(), + max_user_send_per_minute: Self::default_max_user_send_per_minute(), + max_user_send_burst_size: Self::default_max_user_send_burst_size(), + passthrough_senders: Vec::new(), + passthrough_recipients: Vec::new(), + mail_domain: "example.org".to_string(), + mailboxes_dir: None, + } + } +} diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 7b4c28cd..dc981855 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -27,23 +27,16 @@ impl IncomingBeforeQueueHandler { }) } - /// Verify the origin of the email either by performing DKIM verification or by matching the FROM - /// domain literal with the origin IP. + /// Verify the origin of the email by performing a DKIM verification on a regular domain. + /// + /// Currently a no-op for valid domain-literals. async fn verify_origin(&self, envelope: &Envelope, from_addr: &str) -> Result<(), String> { let from_domain = AddressDomain::from_str(from_addr).map_err(|e| e.smtp_response())?; match from_domain { - AddressDomain::Literal(ip) => { - if !envelope.origin_ip.eq_ignore_ascii_case(&ip) { - log::warn!( - "Received invalid origin address: {ip}, actual: {}", - envelope.origin_ip - ); - return Err(format!( - "500 Invalid FROM domain literal: {ip} does not match origin IP {}", - envelope.origin_ip - )); - } + AddressDomain::Literal(_) => { + // Subject to change: we currently don't perform any additional authentication + // for domain-literals and rely purely on encryption. } AddressDomain::Name(domain) => { if !self.skip_dkim @@ -144,3 +137,35 @@ impl SmtpHandler for IncomingBeforeQueueHandler { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use rstest::{fixture, rstest}; + use testresult::TestResult; + + #[fixture] + fn config() -> Config { + Config::default() + } + + /// Test that domain-literals are not rejected by origin check. + #[rstest] + #[tokio::test] + #[case::ipv4(include_bytes!("../test_data/encrypted-ipv4.eml"), "one@[192.0.2.0]")] + #[case::ipv6(include_bytes!("../test_data/encrypted-ipv6.eml"), "one@[IPv6:2001:db8::1]")] + async fn test_domain_literals_allowed( + #[case] eml: &[u8], + #[case] address: &str, + config: Config, + ) -> TestResult { + let handler = IncomingBeforeQueueHandler::new(config, false)?; + let mut envelope = Envelope { + mail_from: address.to_string(), + origin_ip: "".to_string(), // Currently shouldn't be relevant. + data: eml.to_vec(), + rcpt_to: vec!["does.not.matter@example.org".to_string()], + }; + Ok(handler.check_data(&mut envelope).await?) + } +} diff --git a/filtermail/test_data/encrypted-ipv4.eml b/filtermail/test_data/encrypted-ipv4.eml new file mode 100644 index 00000000..0d32e3ac --- /dev/null +++ b/filtermail/test_data/encrypted-ipv4.eml @@ -0,0 +1,66 @@ +From: one@[192.0.2.0] +To: two@example.org +Subject: ... +Date: Sun, 15 Oct 2023 16:43:21 +0000 +Message-ID: +In-Reply-To: +References: + +Chat-Version: 1.0 +Autocrypt: addr=one@example.org; prefer-encrypt=mutual; + keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH + rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID + FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW + XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK + KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ + JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP + IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO +MIME-Version: 1.0 +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="YFrteb74qSXmggbOxZL9dRnhymywAi" + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: PGP/MIME version identification +Content-Type: application/pgp-encrypted + +Version: 1 + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc"; +Content-Type: application/octet-stream; name="encrypted.asc" + +-----BEGIN PGP MESSAGE----- + +wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg +O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae +8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI +JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no +lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz +ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM +YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA +kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI ++lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg +RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo +tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7 +rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp +H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI +fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9 +61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN +XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3 +w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb +NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs +baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW +A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8 +uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI +E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn +lkOWnEbCD+XTnbDd +=agR5 +-----END PGP MESSAGE----- + + +--YFrteb74qSXmggbOxZL9dRnhymywAi-- + + diff --git a/filtermail/test_data/encrypted-ipv6.eml b/filtermail/test_data/encrypted-ipv6.eml new file mode 100644 index 00000000..e728ce97 --- /dev/null +++ b/filtermail/test_data/encrypted-ipv6.eml @@ -0,0 +1,66 @@ +From: one@[IPv6:2001:db8::1] +To: two@example.org +Subject: ... +Date: Sun, 15 Oct 2023 16:43:21 +0000 +Message-ID: +In-Reply-To: +References: + +Chat-Version: 1.0 +Autocrypt: addr=one@example.org; prefer-encrypt=mutual; + keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH + rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID + FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW + XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK + KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ + JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP + IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO +MIME-Version: 1.0 +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="YFrteb74qSXmggbOxZL9dRnhymywAi" + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: PGP/MIME version identification +Content-Type: application/pgp-encrypted + +Version: 1 + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc"; +Content-Type: application/octet-stream; name="encrypted.asc" + +-----BEGIN PGP MESSAGE----- + +wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg +O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae +8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI +JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no +lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz +ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM +YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA +kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI ++lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg +RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo +tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7 +rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp +H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI +fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9 +61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN +XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3 +w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb +NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs +baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW +A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8 +uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI +E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn +lkOWnEbCD+XTnbDd +=agR5 +-----END PGP MESSAGE----- + + +--YFrteb74qSXmggbOxZL9dRnhymywAi-- + + From b31e13a417dd878fc366858d491e20294cf55d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:51:38 +0100 Subject: [PATCH 076/166] chore(release): prepare for 0.6.0 (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 19 +++++++++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index 36ba62ed..022351d1 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,22 @@ +## 0.6.0 - 2026-03-13 + +### Documentation + +- *(readme)* Fix license link +- *(readme)* Clarify licensing of the binaries +- *(readme)* Improve README.md (#88) + +### Features + +- [**breaking**] Remove IP verification for domain-literals (#90) + +### Miscellaneous Tasks + +- *(ci)* Fix binary publish job (#79) + +### Refactor + +- Use enum for mode cli arg (#83) ## 0.5.2 - 2026-02-27 ### Bug Fixes diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index d9dc030e..db16ca05 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -382,7 +382,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.5.2" +version = "0.6.0" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 23d7cf7e..8895d142 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.5.2" +version = "0.6.0" edition = "2024" license = "MIT" readme = "README.md" From 49df79bfa009f4eadb04cb353b757f3696047b4b Mon Sep 17 00:00:00 2001 From: Andrey 0xdc09 <50486086+DarkCat09@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:04:44 +0400 Subject: [PATCH 077/166] feat: configurable hosts for listen and reinject (#84) Adds configurable listen IP and postfix host via `filtermail_host` and `postfix_host` chatmail.ini config fields. --- filtermail/README.md | 11 +++++++++++ filtermail/src/config.rs | 13 +++++++++++++ filtermail/src/inbound.rs | 7 ++++++- filtermail/src/main.rs | 19 ++++++++++++++----- filtermail/src/outbound.rs | 13 +++++++++---- filtermail/src/smtp_client.rs | 12 ++++-------- filtermail/src/smtp_server.rs | 2 +- 7 files changed, 58 insertions(+), 19 deletions(-) diff --git a/filtermail/README.md b/filtermail/README.md index bf29732a..d28d9d21 100644 --- a/filtermail/README.md +++ b/filtermail/README.md @@ -85,6 +85,17 @@ item may start with `@` to whitelist whole recipient domains. - `mailboxes_dir` - path to mailboxes directory, defaults to `/home/vmail/mail/`. +The following options are Filtermail-specific, +they are not read by other chatmail relay components +and usually do not need to be set at all: + +- `filtermail_host` - IP address to listen on, +defaults to `127.0.0.1`. +- `postfix_host` - hostname or IP address where postfix is set up, +a host is resolved only on Filtermail startup, +useful in case MTA runs somewhere outside of localhost, +defaults to `127.0.0.1`. + ### Environment variables Additional options that can be set using environment variables: diff --git a/filtermail/src/config.rs b/filtermail/src/config.rs index f399f9ec..46c3c9f1 100644 --- a/filtermail/src/config.rs +++ b/filtermail/src/config.rs @@ -1,16 +1,21 @@ //! Configuration file handling for filtermail. use serde::{Deserialize, Deserializer}; +use std::net::IpAddr; use std::num::NonZeroU32; use std::path::{Path, PathBuf}; /// Chatmail configuration subset used by filtermail. #[derive(Debug, Clone, Deserialize)] pub struct Config { + #[serde(default = "Config::default_filtermail_host")] + pub filtermail_host: IpAddr, #[serde(default = "Config::default_filtermail_smtp_port")] pub filtermail_smtp_port: u16, #[serde(default = "Config::default_filtermail_smtp_port_incoming")] pub filtermail_smtp_port_incoming: u16, + #[serde(default = "Config::default_postfix_host")] + pub postfix_host: String, #[serde(default = "Config::default_postfix_reinject_port")] pub postfix_reinject_port: u16, #[serde(default = "Config::default_postfix_reinject_port_incoming")] @@ -82,12 +87,18 @@ impl Config { // Following are needed since serde does not support default literals. + const fn default_filtermail_host() -> IpAddr { + IpAddr::V4(std::net::Ipv4Addr::LOCALHOST) + } const fn default_filtermail_smtp_port() -> u16 { 10080 } const fn default_filtermail_smtp_port_incoming() -> u16 { 10081 } + fn default_postfix_host() -> String { + "127.0.0.1".to_owned() + } const fn default_postfix_reinject_port() -> u16 { 10025 } @@ -112,8 +123,10 @@ impl Default for Config { /// Used for tests. fn default() -> Self { Self { + filtermail_host: Self::default_filtermail_host(), filtermail_smtp_port: Self::default_filtermail_smtp_port(), filtermail_smtp_port_incoming: Self::default_filtermail_smtp_port_incoming(), + postfix_host: Self::default_postfix_host(), postfix_reinject_port: Self::default_postfix_reinject_port(), postfix_reinject_port_incoming: Self::default_postfix_reinject_port_incoming(), max_message_size: Self::default_max_message_size(), diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index dc981855..747157a4 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -9,6 +9,7 @@ use crate::smtp_server::SmtpHandler; use crate::utils::{AddressDomain, extract_address, log_eml}; use async_trait::async_trait; use mailparse::{MailHeaderMap, parse_mail}; +use std::net::SocketAddr; use std::str::FromStr; /// Handler for incoming SMTP messages. @@ -16,14 +17,18 @@ pub struct IncomingBeforeQueueHandler { config: Config, dkim_verifier: DkimVerifier, skip_dkim: bool, + reinject_addr: SocketAddr, } impl IncomingBeforeQueueHandler { pub fn new(config: Config, skip_dkim: bool) -> Result { + let reinject_addr = + crate::resolve_addr(&config.postfix_host, config.postfix_reinject_port_incoming)?; Ok(Self { config, dkim_verifier: DkimVerifier::new()?, skip_dkim, + reinject_addr, }) } @@ -127,7 +132,7 @@ impl SmtpHandler for IncomingBeforeQueueHandler { async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { log::debug!("Re-injecting the mail that passed checks"); - crate::smtp_client::send(self.config.postfix_reinject_port_incoming, envelope) + crate::smtp_client::send(self.reinject_addr, envelope) .await .map_err(|e| { log::warn!("Failed to re-inject mail: {}", e); diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index 1992f23c..d923b599 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -41,6 +41,7 @@ use inbound::IncomingBeforeQueueHandler; use outbound::OutgoingBeforeQueueHandler; use smtp_server::run_smtp_server; use std::env; +use std::net::{SocketAddr, ToSocketAddrs}; use std::process; use std::sync::Arc; @@ -107,10 +108,10 @@ async fn main() { }; if mode == Mode::Outgoing { - let handler = Arc::new(OutgoingBeforeQueueHandler::new(config.clone())); - let addr = format!("127.0.0.1:{}", config.filtermail_smtp_port); + let addr = (config.filtermail_host, config.filtermail_smtp_port); + let handler = Arc::new(OutgoingBeforeQueueHandler::new(config.clone()).unwrap()); let max_size = config.max_message_size; - log::debug!("Outgoing SMTP server listening on {addr}"); + log::debug!("Outgoing SMTP server listening on {}:{}", addr.0, addr.1); if let Err(e) = run_smtp_server(&addr, handler, max_size).await { eprintln!("Server error: {}", e); @@ -126,13 +127,13 @@ async fn main() { log::warn!("DKIM verification DISABLED! This should not be used in production."); } + let addr = (config.filtermail_host, config.filtermail_smtp_port_incoming); let handler = Arc::new( // We want to panic here if the handler cannot be created. IncomingBeforeQueueHandler::new(config.clone(), skip_dkim).unwrap(), ); - let addr = format!("127.0.0.1:{}", config.filtermail_smtp_port_incoming); let max_size = config.max_message_size; - log::debug!("Incoming SMTP server listening on {addr}"); + log::debug!("Incoming SMTP server listening on {}:{}", addr.0, addr.1); if let Err(e) = run_smtp_server(&addr, handler, max_size).await { eprintln!("Server error: {}", e); @@ -140,3 +141,11 @@ async fn main() { } } } + +fn resolve_addr(host: &str, port: u16) -> Result { + log::debug!("Resolving {host}"); + Ok((host, port) + .to_socket_addrs()? + .next() + .ok_or(std::io::Error::other("Cannot resolve host"))?) +} diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 6614da66..6b2ab0f7 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -9,21 +9,26 @@ use crate::utils::extract_address; use async_trait::async_trait; use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; use mailparse::{MailHeaderMap, parse_mail}; +use std::net::SocketAddr; /// Handler for outgoing SMTP messages. pub struct OutgoingBeforeQueueHandler { config: Config, + reinject_addr: SocketAddr, send_rate_limiter: DefaultKeyedRateLimiter, } impl OutgoingBeforeQueueHandler { - pub fn new(config: Config) -> Self { + pub fn new(config: Config) -> Result { + let reinject_addr = + crate::resolve_addr(&config.postfix_host, config.postfix_reinject_port)?; let quota = Quota::per_minute(config.max_user_send_per_minute) .allow_burst(config.max_user_send_burst_size); - Self { + Ok(Self { config, + reinject_addr, send_rate_limiter: RateLimiter::keyed(quota), - } + }) } } @@ -125,7 +130,7 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { log::debug!("Re-injecting the mail that passed checks"); - crate::smtp_client::send(self.config.postfix_reinject_port, envelope) + crate::smtp_client::send(self.reinject_addr, envelope) .await .map_err(|e| { log::warn!("Failed to re-inject mail: {}", e); diff --git a/filtermail/src/smtp_client.rs b/filtermail/src/smtp_client.rs index a2dee266..64e9ad95 100644 --- a/filtermail/src/smtp_client.rs +++ b/filtermail/src/smtp_client.rs @@ -1,20 +1,16 @@ use crate::smtp_server::Envelope; -use std::net::{IpAddr, SocketAddr}; +use std::net::SocketAddr; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufStream}; use tokio::net::TcpSocket; -const LOCALHOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)); - -/// Sends an email using an SMTP server at `localhost:`. -pub async fn send(smtp_port: u16, envelope: &Envelope) -> Result<(), crate::error::Error> { +/// Sends an email using an SMTP server at `smtp_addr`. +pub async fn send(smtp_addr: SocketAddr, envelope: &Envelope) -> Result<(), crate::error::Error> { let socket = TcpSocket::new_v4()?; // Disable Nagle's algorithm. socket.set_nodelay(true)?; - let stream = socket - .connect(SocketAddr::new(LOCALHOST, smtp_port)) - .await?; + let stream = socket.connect(smtp_addr).await?; let mut buf_stream = BufStream::new(stream); let mut response = String::new(); diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index e3411590..4114d034 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -43,7 +43,7 @@ pub trait SmtpHandler: Send + Sync { /// Runs the SMTP server on the specified address with the given handler and maximum message size. pub async fn run_smtp_server( - addr: &str, + addr: &impl tokio::net::ToSocketAddrs, handler: Arc, max_size: usize, ) -> Result<(), Box> From b47b859fcb312cf6a0243df149e857e1ccaafd16 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:28:29 +0100 Subject: [PATCH 078/166] chore(deps): Bump actions/download-artifact from 8.0.0 to 8.0.1 (#95) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 8.0.0 to 8.0.1. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index dd436304..076be3fa 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -44,12 +44,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Download aarch64 binary - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: filtermail-x86_64 path: filtermail-x86_64 - name: Download x86_64 binary - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: filtermail-aarch64 path: filtermail-aarch64 From 23b28bc66eabbe8c8656e4a774978a7d8643b463 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:29:54 +0100 Subject: [PATCH 079/166] chore(deps): Bump Swatinem/rust-cache from 2.8.2 to 2.9.1 (#96) Bumps [Swatinem/rust-cache](https://github.com/swatinem/rust-cache) from 2.8.2 to 2.9.1. - [Release notes](https://github.com/swatinem/rust-cache/releases) - [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md) - [Commits](https://github.com/swatinem/rust-cache/compare/779680da715d629ac1d338a641029a2f4372abb5...c19371144df3bb44fab255c43d04cbc2ab54d1c4) --- updated-dependencies: - dependency-name: Swatinem/rust-cache dependency-version: 2.9.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 9d2d8980..d7da02e5 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: with: toolchain: nightly components: rustfmt - - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - uses: taiki-e/install-action@4a136ceac97fe29b942ff3b6c749a46c7950d853 # v2 with: tool: taplo-cli @@ -54,7 +54,7 @@ jobs: with: toolchain: ${{ matrix.toolchain }} components: clippy - - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - run: cargo clippy env: RUSTUP_TOOLCHAIN: ${{ matrix.toolchain }} @@ -73,5 +73,5 @@ jobs: - uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master with: toolchain: stable - - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - run: cargo test From 2aa894f48fb9bb0354a112af630f9bd5d2233e71 Mon Sep 17 00:00:00 2001 From: Andrey 0xdc09 <50486086+DarkCat09@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:49:50 +0400 Subject: [PATCH 080/166] docs(readme): fix typo (#99) As suggested in https://github.com/chatmail/filtermail/pull/84#issuecomment-4056084708 --- filtermail/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/README.md b/filtermail/README.md index d28d9d21..cec7e5f8 100644 --- a/filtermail/README.md +++ b/filtermail/README.md @@ -109,7 +109,7 @@ defaults to `0`. ## Usage outside of chatmail relay **Filtermail development is focused on supporting it as a systemd service used by chatmail relay.** -Althrough unsupported, it may still work outside of this context or even without postfix, +Although unsupported, it may still work outside of this context or even without postfix, with few considerations: - Filtermail expects to receive messages from a trusted server, From 9a4a54cea8c912a9f5705da0755c9e4aca346525 Mon Sep 17 00:00:00 2001 From: Andrey 0xdc09 <50486086+DarkCat09@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:01:52 +0400 Subject: [PATCH 081/166] refactor: derive Default for Envelope (#100) --- filtermail/src/smtp_server.rs | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index 4114d034..53fc9ae6 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -7,7 +7,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; use tokio::net::{TcpListener, TcpStream}; /// Represents an SMTP envelope with sender, recipients, and raw message data. -#[derive(Debug, Clone)] +#[derive(Debug, Default, Clone)] pub struct Envelope { pub mail_from: String, pub origin_ip: String, @@ -86,12 +86,7 @@ where writer.write_all(b"220 filtermail SMTP\r\n").await?; writer.flush().await?; - let mut envelope = Envelope { - mail_from: String::new(), - origin_ip: String::new(), - rcpt_to: Vec::new(), - data: Vec::new(), - }; + let mut envelope = Envelope::default(); 'connection: loop { line.clear(); @@ -199,12 +194,7 @@ where } } - envelope = Envelope { - mail_from: String::new(), - origin_ip: String::new(), - rcpt_to: Vec::new(), - data: Vec::new(), - }; + envelope = Envelope::default(); } else if cmd.to_uppercase().starts_with("XFORWARD") { // https://www.postfix.org/XFORWARD_README.html if let Some(addr_part) = cmd @@ -222,12 +212,7 @@ where writer.flush().await?; break 'connection; } else if cmd.to_uppercase().starts_with("RSET") { - envelope = Envelope { - mail_from: String::new(), - origin_ip: String::new(), - rcpt_to: Vec::new(), - data: Vec::new(), - }; + envelope = Envelope::default(); writer.write_all(b"250 OK\r\n").await?; writer.flush().await?; } else if cmd.to_uppercase().starts_with("NOOP") { From 7da0c34678e92d05daeb74002f1660c431e81a9b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:11:12 +0100 Subject: [PATCH 082/166] chore(deps): Bump taiki-e/install-action from 2.68.20 to 2.69.4 (#102) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.68.20 to 2.69.4. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/4a136ceac97fe29b942ff3b6c749a46c7950d853...a7e592b24782184db1613493a5e65d2e12199f80) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.69.4 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index d7da02e5..8bcb24f6 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - - uses: taiki-e/install-action@4a136ceac97fe29b942ff3b6c749a46c7950d853 # v2 + - uses: taiki-e/install-action@a7e592b24782184db1613493a5e65d2e12199f80 # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index 076be3fa..271e7938 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@4a136ceac97fe29b942ff3b6c749a46c7950d853 # v2 + - uses: taiki-e/install-action@a7e592b24782184db1613493a5e65d2e12199f80 # v2 with: tool: cargo-zigbuild - name: Build From fb77c5be81f56d6d5810f974ac8e789c9a303977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:27:48 +0100 Subject: [PATCH 083/166] feat(resolver): Enable DNSSEC (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From hickory-resolver docs: > To enable DNSSEC, enable the dnssec-ring feature. Related: https://github.com/chatmail/relay/issues/877 Related: https://github.com/hickory-dns/hickory-dns/issues/3519 Closes: https://github.com/chatmail/filtermail/issues/93 Signed-off-by: Jagoda Ślązak --- filtermail/Cargo.lock | 52 +++++++++++++++++++++++++++++++++ filtermail/Cargo.toml | 2 +- filtermail/src/dkim_verifier.rs | 12 +++++++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index db16ca05..1bef5eee 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -268,6 +268,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -582,6 +591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ "async-trait", + "bitflags", "cfg-if", "data-encoding", "enum-as-inner", @@ -593,7 +603,9 @@ dependencies = [ "once_cell", "rand 0.9.2", "ring", + "rustls-pki-types", "thiserror", + "time", "tinyvec", "tokio", "tracing", @@ -919,6 +931,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + [[package]] name = "num-integer" version = "0.1.46" @@ -1060,6 +1078,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1312,6 +1336,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1530,6 +1563,25 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tinystr" version = "0.8.2" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 8895d142..3a2b0f75 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -20,7 +20,7 @@ log = "0.4.29" env_logger = "0.11.9" governor = "0.10.4" viadkim = { version = "0.2.0" } -hickory-resolver = "0.25.2" +hickory-resolver = { version = "0.25.2", features = ["dnssec-ring"] } lru = "0.16.3" parking_lot = "0.12.5" diff --git a/filtermail/src/dkim_verifier.rs b/filtermail/src/dkim_verifier.rs index 9ce00958..97a396f9 100644 --- a/filtermail/src/dkim_verifier.rs +++ b/filtermail/src/dkim_verifier.rs @@ -40,7 +40,17 @@ impl CachedResolver { /// Creates a new [`CachedResolver`]. pub fn new() -> Result { // Use resolv.conf - let dns_resolver = TokioResolver::builder(TokioConnectionProvider::default())?.build(); + let dns_resolver = { + let mut builder = TokioResolver::builder(TokioConnectionProvider::default())?; + // https://github.com/hickory-dns/hickory-dns/issues/3519 + builder.options_mut().validate = true; + builder.build() + }; + + assert!( + dns_resolver.options().validate, + "incorrect resolver config: DNSSEC disabled; exiting" + ); let cache = Arc::new(parking_lot::Mutex::new(LruCache::new(LRU_CACHE_CAPACITY))); From a4497bc10d1e21c33846574c18dd8aa7dd1ae8bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:45:24 +0200 Subject: [PATCH 084/166] chore(deps): Bump dtolnay/rust-toolchain (#105) Bumps [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from efa25f7f19611383d5b0ccf2d1c8914531636bf9 to 3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9. - [Release notes](https://github.com/dtolnay/rust-toolchain/releases) - [Commits](https://github.com/dtolnay/rust-toolchain/compare/efa25f7f19611383d5b0ccf2d1c8914531636bf9...3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9) --- updated-dependencies: - dependency-name: dtolnay/rust-toolchain dependency-version: 3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 6 +++--- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 8bcb24f6..d6e9c16c 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master with: toolchain: nightly components: rustfmt @@ -50,7 +50,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master with: toolchain: ${{ matrix.toolchain }} components: clippy @@ -70,7 +70,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master with: toolchain: stable - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index 271e7938..5ad6ac54 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master with: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl From ca3393cf480bb0fe8c58320cc621aa98bc23c777 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:45:50 +0200 Subject: [PATCH 085/166] chore(deps): Bump env_logger from 0.11.9 to 0.11.10 (#106) Bumps [env_logger](https://github.com/rust-cli/env_logger) from 0.11.9 to 0.11.10. - [Release notes](https://github.com/rust-cli/env_logger/releases) - [Changelog](https://github.com/rust-cli/env_logger/blob/main/CHANGELOG.md) - [Commits](https://github.com/rust-cli/env_logger/compare/v0.11.9...v0.11.10) --- updated-dependencies: - dependency-name: env_logger dependency-version: 0.11.10 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/Cargo.lock | 20 ++++++++++---------- filtermail/Cargo.toml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 1bef5eee..08ff8217 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -19,9 +19,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -40,9 +40,9 @@ checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -356,9 +356,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -781,9 +781,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "jiff" -version = "0.2.18" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" dependencies = [ "jiff-static", "log", @@ -794,9 +794,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.18" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 3a2b0f75..9d0b6ac1 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -17,7 +17,7 @@ tokio = { version = "1.50.0", features = ["full"] } thiserror = "2.0.18" mailparse = "0.16.1" log = "0.4.29" -env_logger = "0.11.9" +env_logger = "0.11.10" governor = "0.10.4" viadkim = { version = "0.2.0" } hickory-resolver = { version = "0.25.2", features = ["dnssec-ring"] } From 621eaeeffb8a9af09518e6aa9fce298b19364850 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:46:08 +0200 Subject: [PATCH 086/166] chore(deps): Bump taiki-e/install-action from 2.69.4 to 2.69.13 (#107) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.69.4 to 2.69.13. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/a7e592b24782184db1613493a5e65d2e12199f80...704f92c11daa75bff5b4e01fcb083350c16c47b9) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.69.13 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index d6e9c16c..73e2e356 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - - uses: taiki-e/install-action@a7e592b24782184db1613493a5e65d2e12199f80 # v2 + - uses: taiki-e/install-action@704f92c11daa75bff5b4e01fcb083350c16c47b9 # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index 5ad6ac54..f558ff9b 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@a7e592b24782184db1613493a5e65d2e12199f80 # v2 + - uses: taiki-e/install-action@704f92c11daa75bff5b4e01fcb083350c16c47b9 # v2 with: tool: cargo-zigbuild - name: Build From 05cbf2908c38a315976bdc10149a47d0e6504d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:57:02 +0200 Subject: [PATCH 087/166] feat: Add experimental option to disable mailboxes (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/src/config.rs | 26 +++++++++++++++++++------- filtermail/src/inbound.rs | 7 +++++++ filtermail/src/outbound.rs | 25 +++++++++++++++++++++++++ filtermail/src/smtp_server.rs | 4 ++++ 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/filtermail/src/config.rs b/filtermail/src/config.rs index 46c3c9f1..ad6831c5 100644 --- a/filtermail/src/config.rs +++ b/filtermail/src/config.rs @@ -72,17 +72,29 @@ impl Config { } } - /// Check if not encrypted mail is allowed for the given address. - pub fn is_cleartext_ok(&self, addr: &str) -> bool { + /// Check if a specific flag file exists for the given address. + /// + /// Returns `default` if the address is invalid. + fn check_flag(&self, addr: &str, flag: &str, default: bool) -> bool { if addr.is_empty() || !addr.contains('@') || addr.contains('/') { - return false; + return default; } - let mut enforce_e2ee = self.mailboxes_dir(); - enforce_e2ee.push(addr); - enforce_e2ee.push("enforceE2EEincoming"); + let mut path = self.mailboxes_dir(); + path.push(addr); + path.push(flag); - !enforce_e2ee.exists() + path.exists() + } + + /// Check if not encrypted mail is allowed for the given address. + pub fn is_cleartext_ok(&self, addr: &str) -> bool { + !self.check_flag(addr, "enforceE2EEincoming", true) + } + + /// Check if the given address is disabled. + pub fn is_disabled(&self, addr: &str) -> bool { + self.check_flag(addr, "DISABLED", false) } // Following are needed since serde does not support default literals. diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 747157a4..9af1f1e8 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -98,6 +98,13 @@ impl SmtpHandler for IncomingBeforeQueueHandler { envelope.mail_from = String::new(); } + envelope.rcpt_to = envelope + .rcpt_to + .iter() + .filter(|s| !self.config.is_disabled(s)) + .cloned() + .collect(); + let mail_encrypted = check_encrypted(&message, false); log::debug!("mail_encrypted: {mail_encrypted}"); log::debug!("is_securejoin: {}", is_securejoin(&message)); diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 6b2ab0f7..5db04e05 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -78,6 +78,13 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { let from_addr = extract_address(&from_header) .ok_or(format!("500 Invalid FROM header: {from_header}"))?; + envelope.rcpt_to = envelope + .rcpt_to + .iter() + .filter(|s| !self.config.is_disabled(s)) + .cloned() + .collect(); + // MAIL FROM is our source of truth for outbound messages, // as this address is checked by postfix against the username before sending it // to filtermail. @@ -139,4 +146,22 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { Ok(()) } + + async fn handle_data(&self, envelope: &mut Envelope) -> Result { + log::debug!("handle_DATA before-queue"); + self.check_data(envelope).await?; + if self.config.is_disabled(&envelope.mail_from) { + log::warn!("Dropping mail; Sender {} is disabled.", envelope.mail_from); + return Ok("250 OK".to_string()); + } + if envelope.rcpt_to.is_empty() { + log::warn!("Dropping mail; All recipients disabled."); + return Ok("250 OK".to_string()); + } + self.reinject_mail(envelope).await.map_err(|e| { + log::warn!("Failed to reinject mail: {e}"); + e + })?; + Ok("250 OK".to_string()) + } } diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index 53fc9ae6..ef7357a2 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -33,6 +33,10 @@ pub trait SmtpHandler: Send + Sync { async fn handle_data(&self, envelope: &mut Envelope) -> Result { log::debug!("handle_DATA before-queue"); self.check_data(envelope).await?; + if envelope.rcpt_to.is_empty() { + log::warn!("Dropping mail; All recipients disabled."); + return Ok("250 OK".to_string()); + } self.reinject_mail(envelope).await.map_err(|e| { log::warn!("Failed to reinject mail: {e}"); e From 0f5894299852f6eebff5c19af5c09c79d1a6eed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:21:32 +0200 Subject: [PATCH 088/166] chore(release): prepare for 0.6.1 (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 15 +++++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index 022351d1..862b9e95 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,18 @@ +## 0.6.1 - 2026-04-01 + +### Documentation + +- *(readme)* Fix typo (#99) + +### Features + +- Configurable hosts for listen and reinject (#84) +- *(resolver)* Enable DNSSEC (#94) +- Add experimental option to disable mailboxes (#108) + +### Refactor + +- Derive Default for Envelope (#100) ## 0.6.0 - 2026-03-13 ### Documentation diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 08ff8217..1601ba8e 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -391,7 +391,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 9d0b6ac1..7acdc219 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.6.0" +version = "0.6.1" edition = "2024" license = "MIT" readme = "README.md" From 5575df75105a63d47d7de2f39d62864e4f91da9c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:46:10 +0200 Subject: [PATCH 089/166] chore(deps): Bump taiki-e/install-action from 2.69.13 to 2.71.2 (#112) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.69.13 to 2.71.2. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/704f92c11daa75bff5b4e01fcb083350c16c47b9...d858f8113943481093e02986a7586a4819a3bfd6) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.71.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 73e2e356..bcca9365 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - - uses: taiki-e/install-action@704f92c11daa75bff5b4e01fcb083350c16c47b9 # v2 + - uses: taiki-e/install-action@d858f8113943481093e02986a7586a4819a3bfd6 # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index f558ff9b..c3f334ee 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@704f92c11daa75bff5b4e01fcb083350c16c47b9 # v2 + - uses: taiki-e/install-action@d858f8113943481093e02986a7586a4819a3bfd6 # v2 with: tool: cargo-zigbuild - name: Build From 8ad65923e960860cf57b9c0e55ceb645b1ad5f91 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:46:59 +0200 Subject: [PATCH 090/166] chore(deps): Bump tokio from 1.50.0 to 1.51.0 (#111) Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.50.0 to 1.51.0. - [Release notes](https://github.com/tokio-rs/tokio/releases) - [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.50.0...tokio-1.51.0) --- updated-dependencies: - dependency-name: tokio dependency-version: 1.51.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/Cargo.lock | 100 ++++++------------------------------------ filtermail/Cargo.toml | 2 +- 2 files changed, 14 insertions(+), 88 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 1601ba8e..7b2a0864 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -824,9 +824,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.180" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libm" @@ -883,9 +883,9 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -1464,12 +1464,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1609,9 +1609,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" dependencies = [ "bytes", "libc", @@ -1619,16 +1619,16 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -1919,15 +1919,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -1961,30 +1952,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -1997,12 +1971,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -2015,12 +1983,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -2033,24 +1995,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -2063,12 +2013,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -2081,12 +2025,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -2099,12 +2037,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -2117,12 +2049,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.14" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 7acdc219..ba032860 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -13,7 +13,7 @@ async-trait = "0.1.89" base64 = "0.22.1" serde = { version = "1.0.228", features = ["derive"] } serini = "0.2.2" -tokio = { version = "1.50.0", features = ["full"] } +tokio = { version = "1.51.0", features = ["full"] } thiserror = "2.0.18" mailparse = "0.16.1" log = "0.4.29" From 0feb1371b3bf7b57a7687ff5c449daa514df78e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:53:48 +0200 Subject: [PATCH 091/166] chore(dependabot): Update configuration (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/.github/dependabot.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/filtermail/.github/dependabot.yml b/filtermail/.github/dependabot.yml index 5d0ffc61..83fb5346 100644 --- a/filtermail/.github/dependabot.yml +++ b/filtermail/.github/dependabot.yml @@ -3,9 +3,13 @@ updates: - package-ecosystem: "cargo" directory: "/" schedule: - interval: "weekly" + interval: "monthly" + cooldown: + default-days: 7 - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 From 2450d1d9c74334772a624cc3c9dd1aaacddd536a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:27:48 +0200 Subject: [PATCH 092/166] docs(readme): Reformat README.md (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: #91 Signed-off-by: Jagoda Ślązak --- filtermail/README.md | 82 ++++++++++++++++++++++---------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/filtermail/README.md b/filtermail/README.md index cec7e5f8..d0b044b1 100644 --- a/filtermail/README.md +++ b/filtermail/README.md @@ -24,22 +24,22 @@ Filtermail in incoming mode performs following steps: 1. Rejects messages if `DATA` exceeds configured message size limit. 2. Rejects messages that do not meet at least one of the following criteria: - - PGP encrypted, - - securejoin message, - - mailer-daemon message, - - all recipients allow cleartext - (`enforceE2EEincoming` is not present in their mailbox directory). + - PGP encrypted, + - securejoin message, + - mailer-daemon message, + - all recipients allow cleartext + (`enforceE2EEincoming` is not present in their mailbox directory). 3. If `MAIL FROM` doesn't match `From` header, -the address is removed from `MAIL FROM` on reinjection -(prevents bounces to possibly spoofed `MAIL FROM`). + the address is removed from `MAIL FROM` on reinjection + (prevents bounces to possibly spoofed `MAIL FROM`). 4. Checks message origin, -depending on address type: - - **domain** - performs a strict DKIM verification and domain alignment check - (domain of address from `From` header must exactly match the DKIM signature domain), - rejecting messages that fail. - - **domain-literal (IP address)** - currently no-op. + depending on address type: + - **domain** - performs a strict DKIM verification and domain alignment check + (domain of address from `From` header must exactly match the DKIM signature domain), + rejecting messages that fail. + - **domain-literal (IP address)** - currently no-op. 5. In case of a DKIM failure, -the message is saved to `/tmp/filtermail-rejected/dkim-verify` directory for later inspection. + the message is saved to `/tmp/filtermail-rejected/dkim-verify` directory for later inspection. ### Outgoing mode @@ -49,11 +49,11 @@ Filtermail in outgoing mode performs following steps: 2. Rejects messages if `DATA` exceeds configured message size limit. 3. Rejects messages which `From` header address does not match one in `MAIL FROM`. 4. Rejects messages that do not meet at least one of the following criteria: - - PGP encrypted, - - securejoin message, - - sender is in `passthrough_senders`, - - self-sent Autocrypt Setup Message, - - all recipients match `passthrough_recipients`. + - PGP encrypted, + - securejoin message, + - sender is in `passthrough_senders`, + - self-sent Autocrypt Setup Message, + - all recipients match `passthrough_recipients`. ## Configuration @@ -63,48 +63,48 @@ Filtermail shares the same configuration file as chatmail relay, but implements a custom parser that only requires a small subset of configuration options: - `filtermail_smtp_port` - port to listen on in outgoing mode, -defaults to `10080`. + defaults to `10080`. - `filtermail_smtp_port_incoming` - port to listen on in incoming mode, -defaults to `10081`. + defaults to `10081`. - `postfix_reinject_port` - port to reinject messages to postfix in outgoing mode, -defaults to `10025`. + defaults to `10025`. - `postfix_reinject_port_incoming` - port to reinject messages to postfix in incoming mode, -defaults to `10026`. + defaults to `10026`. - `max_message_size` - maximum allowed message size in bytes, -defaults to `31457280` (30 MiB). + defaults to `31457280` (30 MiB). - `max_user_send_per_minute` - email sending rate per user and minute, -defaults to `60`. + defaults to `60`. - `max_user_send_burst_size` - per-user max burst size for sending rate limiting (GCRA bucket capacity), -defaults to `10`. + defaults to `10`. - `passthrough_senders` - space separated list of email addresses -which can send outbound un-encrypted mail. + which can send outbound un-encrypted mail. - `passthrough_recipients` - space separated list of email addresses -which can receive inbound un-encrypted mail, -item may start with `@` to whitelist whole recipient domains. + which can receive inbound un-encrypted mail, + item may start with `@` to whitelist whole recipient domains. - `mail_domain` - domain name used in email addresses. - `mailboxes_dir` - path to mailboxes directory, -defaults to `/home/vmail/mail/`. + defaults to `/home/vmail/mail/`. The following options are Filtermail-specific, they are not read by other chatmail relay components and usually do not need to be set at all: - `filtermail_host` - IP address to listen on, -defaults to `127.0.0.1`. + defaults to `127.0.0.1`. - `postfix_host` - hostname or IP address where postfix is set up, -a host is resolved only on Filtermail startup, -useful in case MTA runs somewhere outside of localhost, -defaults to `127.0.0.1`. + a host is resolved only on Filtermail startup, + useful in case MTA runs somewhere outside of localhost, + defaults to `127.0.0.1`. ### Environment variables Additional options that can be set using environment variables: - `RUST_LOG` - set log level, -defaults to `info`. + defaults to `info`. - `FILTERMAIL_SKIP_DKIM` - completely skip DKIM verification; -only for testing purposes and not recommended for production use, -defaults to `0`. + only for testing purposes and not recommended for production use, + defaults to `0`. ## Usage outside of chatmail relay @@ -113,11 +113,11 @@ Although unsupported, it may still work outside of this context or even without with few considerations: - Filtermail expects to receive messages from a trusted server, -and thus should not be exposed directly to the internet. + and thus should not be exposed directly to the internet. - Issues outside of chatmail relay context are not necessarily considered bugs; -PRs fixing them are not guaranteed to be accepted. -(Trivial changes may still be considered, -please open an issue to discuss any such changes before working on them). + PRs fixing them are not guaranteed to be accepted. + (Trivial changes may still be considered, + please open an issue to discuss any such changes before working on them). ## Releases @@ -131,5 +131,5 @@ Binaries are available on the [releases page](https://github.com/chatmail/filter Code licensed under [MIT](LICENSE). -Binary releases of `filtermail` link with `viadkim` +Binary releases of `filtermail` link with `viadkim` and are thus subject to the [GPL-3.0-or-later](LICENSE-GPL). From ec0413bd5fabdc861471932cce50d5f2a1e52def Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:00:42 +0200 Subject: [PATCH 093/166] feat: Log disabled recipients (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs disabled recipient addresses when removing them from RCPT TO. Signed-off-by: Jagoda Ślązak --- filtermail/src/inbound.rs | 8 +++++++- filtermail/src/outbound.rs | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 9af1f1e8..b2921b7d 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -101,7 +101,13 @@ impl SmtpHandler for IncomingBeforeQueueHandler { envelope.rcpt_to = envelope .rcpt_to .iter() - .filter(|s| !self.config.is_disabled(s)) + .filter(|s| { + let disabled = self.config.is_disabled(s); + if disabled { + log::warn!("Disabled recipient: {s}; removing from RCPT TO"); + } + !disabled + }) .cloned() .collect(); diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 5db04e05..5c60808b 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -81,7 +81,13 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { envelope.rcpt_to = envelope .rcpt_to .iter() - .filter(|s| !self.config.is_disabled(s)) + .filter(|s| { + let disabled = self.config.is_disabled(s); + if disabled { + log::warn!("Disabled recipient: {s}; removing from RCPT TO"); + } + !disabled + }) .cloned() .collect(); From cf64778f710829f50153c630b225bc0cf56ba882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:12:09 +0200 Subject: [PATCH 094/166] feat(transport): Remote delivery over SMTP (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a new mode "transport", that allows filtermail to be used for remote delivery. In transport mode, filtermail listens for LMTP connections, splits messages by domain and performs delivery to remote MTAs over SMTP. SMTP client tries to open socket on all resolved addresses in parallel and uses one that succeeds the first, fixing the issue described in: chatmail/relay#900 Extends the built-in SMTP client with STARTTLS support. Extends the built-in SMTP server with LMTP greeting support. Groundwork required for mxdeliv endpoint (HTTP channel for MTA-to-MTA communication): chatmail/relay#900 Signed-off-by: Jagoda Ślązak --- filtermail/Cargo.lock | 70 +++++++++- filtermail/Cargo.toml | 7 + filtermail/README.md | 24 +++- filtermail/src/config.rs | 6 + filtermail/src/dkim_verifier.rs | 29 +--- filtermail/src/error.rs | 17 +++ filtermail/src/inbound.rs | 35 +++-- filtermail/src/main.rs | 117 ++++++++++------ filtermail/src/outbound.rs | 33 +++-- filtermail/src/smtp_client.rs | 238 ++++++++++++++++++++++++++++++-- filtermail/src/smtp_server.rs | 9 +- filtermail/src/tls.rs | 46 ++++++ filtermail/src/tls/danger.rs | 49 +++++++ filtermail/src/transport.rs | 227 ++++++++++++++++++++++++++++++ filtermail/src/utils.rs | 30 +++- 15 files changed, 816 insertions(+), 121 deletions(-) create mode 100644 filtermail/src/tls.rs create mode 100644 filtermail/src/tls/danger.rs create mode 100644 filtermail/src/transport.rs diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 7b2a0864..23351923 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -98,9 +98,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -408,7 +408,10 @@ dependencies = [ "testresult", "thiserror", "tokio", + "tokio-io-timeout", + "tokio-rustls", "viadkim", + "webpki-roots", ] [[package]] @@ -933,9 +936,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -1336,6 +1339,21 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -1345,6 +1363,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.103.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1609,9 +1638,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.0" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", @@ -1624,6 +1653,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -1635,6 +1674,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -1867,6 +1916,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "widestring" version = "1.2.1" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index ba032860..e6a4db8e 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -23,6 +23,13 @@ viadkim = { version = "0.2.0" } hickory-resolver = { version = "0.25.2", features = ["dnssec-ring"] } lru = "0.16.3" parking_lot = "0.12.5" +tokio-rustls = { version = "0.26.4", default-features = false, features = [ + "ring", + "logging", + "tls12", +] } +webpki-roots = "1.0.6" +tokio-io-timeout = "1.2.1" [dev-dependencies] rstest = "0.26.1" diff --git a/filtermail/README.md b/filtermail/README.md index d0b044b1..500bdaed 100644 --- a/filtermail/README.md +++ b/filtermail/README.md @@ -11,16 +11,16 @@ and handles per-sender rate limiting. ## Usage ```plain -filtermail (incoming|outgoing) +filtermail (incoming|outgoing|transport) ``` where `` is a path to `chatmail.ini` configuration file. -Filtermail can be used in `incoming` or `outgoing` mode that apply different settings -to filter either incoming or outgoing emails. +Filtermail can be used in `incoming`, `outgoing` or `transport` mode. ### Incoming mode -Filtermail in incoming mode performs following steps: +Filtermail in incoming mode acts as a proxy filter +for messages received from remote MTAs and performs following steps: 1. Rejects messages if `DATA` exceeds configured message size limit. 2. Rejects messages that do not meet at least one of the following criteria: @@ -43,7 +43,8 @@ Filtermail in incoming mode performs following steps: ### Outgoing mode -Filtermail in outgoing mode performs following steps: +Filtermail in outgoing mode acts as a proxy filter +for messages received from clients and performs following steps: 1. Rejects messages at `MAIL FROM` stage if the address exceeded rate limit. 2. Rejects messages if `DATA` exceeds configured message size limit. @@ -55,6 +56,15 @@ Filtermail in outgoing mode performs following steps: - self-sent Autocrypt Setup Message, - all recipients match `passthrough_recipients`. +### Transport mode + +Filtermail in transport mode is used for final delivery to remote MTAs. +As opposed to incoming/outgoing, it accepts connections from postfix over LMTP instead of SMTP, +to allow returning per-recipient status back to postfix. +Received message is split per-domain and sent to recipients' MX servers over SMTP, +enforcing TLS. +As opposed to postfix, IPv4 and IPv6 connections are tried in parallel and first successful connection is used. + ## Configuration ### chatmail.ini @@ -66,6 +76,8 @@ but implements a custom parser that only requires a small subset of configuratio defaults to `10080`. - `filtermail_smtp_port_incoming` - port to listen on in incoming mode, defaults to `10081`. +- `filtermail_lmtp_port_transport` - port to listen on in transport mode, + defaults to `10083`. - `postfix_reinject_port` - port to reinject messages to postfix in outgoing mode, defaults to `10025`. - `postfix_reinject_port_incoming` - port to reinject messages to postfix in incoming mode, @@ -113,7 +125,7 @@ Although unsupported, it may still work outside of this context or even without with few considerations: - Filtermail expects to receive messages from a trusted server, - and thus should not be exposed directly to the internet. + and thus should not listen on ports exposed directly to the internet. - Issues outside of chatmail relay context are not necessarily considered bugs; PRs fixing them are not guaranteed to be accepted. (Trivial changes may still be considered, diff --git a/filtermail/src/config.rs b/filtermail/src/config.rs index ad6831c5..71a9bc18 100644 --- a/filtermail/src/config.rs +++ b/filtermail/src/config.rs @@ -14,6 +14,8 @@ pub struct Config { pub filtermail_smtp_port: u16, #[serde(default = "Config::default_filtermail_smtp_port_incoming")] pub filtermail_smtp_port_incoming: u16, + #[serde(default = "Config::default_filtermail_lmtp_port_transport")] + pub filtermail_lmtp_port_transport: u16, #[serde(default = "Config::default_postfix_host")] pub postfix_host: String, #[serde(default = "Config::default_postfix_reinject_port")] @@ -108,6 +110,9 @@ impl Config { const fn default_filtermail_smtp_port_incoming() -> u16 { 10081 } + const fn default_filtermail_lmtp_port_transport() -> u16 { + 10083 + } fn default_postfix_host() -> String { "127.0.0.1".to_owned() } @@ -138,6 +143,7 @@ impl Default for Config { filtermail_host: Self::default_filtermail_host(), filtermail_smtp_port: Self::default_filtermail_smtp_port(), filtermail_smtp_port_incoming: Self::default_filtermail_smtp_port_incoming(), + filtermail_lmtp_port_transport: Self::default_filtermail_lmtp_port_transport(), postfix_host: Self::default_postfix_host(), postfix_reinject_port: Self::default_postfix_reinject_port(), postfix_reinject_port_incoming: Self::default_postfix_reinject_port_incoming(), diff --git a/filtermail/src/dkim_verifier.rs b/filtermail/src/dkim_verifier.rs index 97a396f9..b12e0cb3 100644 --- a/filtermail/src/dkim_verifier.rs +++ b/filtermail/src/dkim_verifier.rs @@ -1,4 +1,3 @@ -use hickory_resolver::name_server::TokioConnectionProvider; use hickory_resolver::{Name, TokioResolver}; use lru::LruCache; use std::io; @@ -29,7 +28,7 @@ fn normalize_rdata(txt_data: &str) -> String { /// DNS resolver for DKIM TXT records, that caches RDATA in memory. #[derive(Clone)] struct CachedResolver { - dns_resolver: TokioResolver, + dns_resolver: Arc, // Note: Arc is required despite we are holding the whole handler in an Arc, // because viadkim will internally clone the resolver (LookupTxt + Clone + 'static) // to parallelize lookups in case of multiple signatures... @@ -38,26 +37,13 @@ struct CachedResolver { impl CachedResolver { /// Creates a new [`CachedResolver`]. - pub fn new() -> Result { - // Use resolv.conf - let dns_resolver = { - let mut builder = TokioResolver::builder(TokioConnectionProvider::default())?; - // https://github.com/hickory-dns/hickory-dns/issues/3519 - builder.options_mut().validate = true; - builder.build() - }; - - assert!( - dns_resolver.options().validate, - "incorrect resolver config: DNSSEC disabled; exiting" - ); - + pub fn new(dns_resolver: Arc) -> Self { let cache = Arc::new(parking_lot::Mutex::new(LruCache::new(LRU_CACHE_CAPACITY))); - Ok(Self { + Self { dns_resolver, cache, - }) + } } /// Invalidates the cached RDATA for a given selector and domain. @@ -148,7 +134,6 @@ impl LookupTxt for MockResolver { } /// Either a real resolver or a mock. -#[allow(clippy::large_enum_variant)] #[derive(Clone)] enum Resolver { /// A [`CachedResolver`] @@ -190,13 +175,13 @@ pub struct DkimVerifier { impl DkimVerifier { /// Creates a new [`DkimVerifier`] with the provided resolver. - pub fn new() -> Result { - let resolver = CachedResolver::new()?.into(); + pub fn new(dns_resolver: Arc) -> Self { + let resolver = CachedResolver::new(dns_resolver).into(); let config = viadkim::Config { lookup_timeout: Duration::from_secs(60), ..Default::default() }; - Ok(Self { resolver, config }) + Self { resolver, config } } /// Creates a new [`DkimVerifier`] with a mock resolver that always returns the provided TXT record. diff --git a/filtermail/src/error.rs b/filtermail/src/error.rs index b8593634..32bea648 100644 --- a/filtermail/src/error.rs +++ b/filtermail/src/error.rs @@ -1,5 +1,7 @@ //! Error types. +use tokio_rustls::rustls; + /// Error type for filtermail. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -19,6 +21,12 @@ pub enum Error { }, #[error("Invalid email address: {0}")] InvalidEmailAddress(String), + #[error("Failed to connect to any of the following addresses: {0:?}")] + ConnectionFailed(Vec), + #[error(transparent)] + Tls(#[from] rustls::Error), + #[error(transparent)] + InvalidDnsName(#[from] rustls::pki_types::InvalidDnsNameError), } impl Error { @@ -34,4 +42,13 @@ impl Error { _ => "451 Local error".to_string(), } } + + /// Same as [`smtp_response`](Self::smtp_response) but formats the same response + /// for each recipient, as expected by LMTP. + pub fn lmtp_response(&self, recipient_count: usize) -> String { + let response = self.smtp_response(); + std::iter::repeat_n(response, recipient_count) + .collect::>() + .join("\r\n") + } } diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index b2921b7d..7b9182e3 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -6,29 +6,29 @@ use crate::dkim_verifier::DkimVerifier; use crate::message::{check_encrypted, is_securejoin}; pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; -use crate::utils::{AddressDomain, extract_address, log_eml}; +use crate::utils::{AddressDomain, build_resolver, extract_address, log_eml}; use async_trait::async_trait; +use hickory_resolver::TokioResolver; use mailparse::{MailHeaderMap, parse_mail}; -use std::net::SocketAddr; use std::str::FromStr; +use std::sync::Arc; /// Handler for incoming SMTP messages. pub struct IncomingBeforeQueueHandler { config: Config, + dns_resolver: Arc, dkim_verifier: DkimVerifier, skip_dkim: bool, - reinject_addr: SocketAddr, } impl IncomingBeforeQueueHandler { pub fn new(config: Config, skip_dkim: bool) -> Result { - let reinject_addr = - crate::resolve_addr(&config.postfix_host, config.postfix_reinject_port_incoming)?; + let dns_resolver = Arc::new(build_resolver()?); Ok(Self { config, - dkim_verifier: DkimVerifier::new()?, + dns_resolver: dns_resolver.clone(), + dkim_verifier: DkimVerifier::new(dns_resolver), skip_dkim, - reinject_addr, }) } @@ -144,13 +144,20 @@ impl SmtpHandler for IncomingBeforeQueueHandler { async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { log::debug!("Re-injecting the mail that passed checks"); - - crate::smtp_client::send(self.reinject_addr, envelope) - .await - .map_err(|e| { - log::warn!("Failed to re-inject mail: {}", e); - e.smtp_response() - })?; + let hostname = format!("[{}]", self.config.filtermail_host); + crate::smtp_client::send( + &self.config.postfix_host, + self.config.postfix_reinject_port_incoming, + envelope, + &hostname, + None, + self.dns_resolver.clone(), + ) + .await + .map_err(|e| { + log::warn!("Failed to re-inject mail: {}", e); + e.smtp_response() + })?; Ok(()) } diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index d923b599..47d2ed45 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -33,40 +33,45 @@ pub(crate) mod openpgp; pub(crate) mod outbound; pub(crate) mod smtp_client; pub(crate) mod smtp_server; +mod tls; +mod transport; pub(crate) mod utils; +use crate::transport::TransportHandler; use config::Config; use env_logger::Env; use inbound::IncomingBeforeQueueHandler; use outbound::OutgoingBeforeQueueHandler; use smtp_server::run_smtp_server; use std::env; -use std::net::{SocketAddr, ToSocketAddrs}; use std::process; +use std::str::FromStr; use std::sync::Arc; const ENCRYPTION_NEEDED_523: &str = "523 Encryption Needed: Invalid Unencrypted Mail"; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] enum Mode { Outgoing, Incoming, + Transport, } -impl std::str::FromStr for Mode { +impl FromStr for Mode { type Err = &'static str; - fn from_str(s: &str) -> Result { - match s { + fn from_str(mode: &str) -> Result { + match mode { "outgoing" => Ok(Mode::Outgoing), "incoming" => Ok(Mode::Incoming), - _ => Err("Error: mode must be 'incoming' or 'outgoing'"), + "transport" => Ok(Mode::Transport), + _ => Err("Error: mode must be 'incoming', 'outgoing' or 'transport'"), } } } #[tokio::main] -async fn main() { +async fn main() -> Result<(), error::Error> { // default to info level let env = Env::new().filter_or("RUST_LOG", "info"); env_logger::Builder::from_env(env) @@ -74,13 +79,17 @@ async fn main() { .format_timestamp(None) .init(); + tokio_rustls::rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to set up rustls crypto provider."); + let args: Vec = env::args().collect(); if args.len() != 3 { eprintln!( "Usage: {} ", args.first().unwrap_or(&"filtermail".to_string()) ); - eprintln!(" mode: incoming or outgoing"); + eprintln!(" mode: incoming, outgoing or transport"); process::exit(1); } @@ -91,7 +100,7 @@ async fn main() { unreachable!("args length checked above") }; - let mode = match mode.parse::() { + let mode = match Mode::from_str(mode) { Ok(mode) => mode, Err(e) => { eprintln!("{e}"); @@ -107,45 +116,65 @@ async fn main() { } }; - if mode == Mode::Outgoing { - let addr = (config.filtermail_host, config.filtermail_smtp_port); - let handler = Arc::new(OutgoingBeforeQueueHandler::new(config.clone()).unwrap()); - let max_size = config.max_message_size; - log::debug!("Outgoing SMTP server listening on {}:{}", addr.0, addr.1); + match mode { + Mode::Outgoing => { + let addr = (config.filtermail_host, config.filtermail_smtp_port); + let handler = Arc::new(OutgoingBeforeQueueHandler::new(config.clone())?); + let max_size = config.max_message_size; + log::debug!("Outgoing SMTP server listening on {}:{}", addr.0, addr.1); - if let Err(e) = run_smtp_server(&addr, handler, max_size).await { - eprintln!("Server error: {}", e); - process::exit(1); + if let Err(e) = run_smtp_server(&addr, handler, max_size).await { + eprintln!("Server error: {}", e); + process::exit(1); + } } - } else { - // Skip DKIM verification (used for tests). - let skip_dkim = env::var("FILTERMAIL_SKIP_DKIM") - .map(|val| val == "1" || val.eq_ignore_ascii_case("true")) - .unwrap_or(false); + Mode::Incoming => { + // Skip DKIM verification (used for tests). + let skip_dkim = env::var("FILTERMAIL_SKIP_DKIM") + .map(|val| val == "1" || val.eq_ignore_ascii_case("true")) + .unwrap_or(false); - if skip_dkim { - log::warn!("DKIM verification DISABLED! This should not be used in production."); + if skip_dkim { + log::warn!("DKIM verification DISABLED! This should not be used in production."); + } + + let handler = Arc::new(IncomingBeforeQueueHandler::new(config.clone(), skip_dkim)?); + let max_size = config.max_message_size; + + let mut server_set = tokio::task::JoinSet::new(); + + let addr_smtp = (config.filtermail_host, config.filtermail_smtp_port_incoming); + let handler_smtp = handler.clone(); + server_set + .spawn(async move { run_smtp_server(&addr_smtp, handler_smtp, max_size).await }); + log::debug!( + "Incoming SMTP server listening on {}:{}", + addr_smtp.0, + addr_smtp.1 + ); + + while let Some(result) = server_set.join_next().await { + if let Err(e) = result { + eprintln!("Server error: {}", e); + process::exit(1); + } + } } + Mode::Transport => { + let addr = ( + config.filtermail_host, + config.filtermail_lmtp_port_transport, + ); + let handler = Arc::new(TransportHandler::new(config.clone())?); + let max_size = config.max_message_size; + log::debug!("Transport SMTP server listening on {}:{}", addr.0, addr.1); - let addr = (config.filtermail_host, config.filtermail_smtp_port_incoming); - let handler = Arc::new( - // We want to panic here if the handler cannot be created. - IncomingBeforeQueueHandler::new(config.clone(), skip_dkim).unwrap(), - ); - let max_size = config.max_message_size; - log::debug!("Incoming SMTP server listening on {}:{}", addr.0, addr.1); - - if let Err(e) = run_smtp_server(&addr, handler, max_size).await { - eprintln!("Server error: {}", e); - process::exit(1); + if let Err(e) = run_smtp_server(&addr, handler, max_size).await { + eprintln!("Server error: {}", e); + process::exit(1); + } } - } -} - -fn resolve_addr(host: &str, port: u16) -> Result { - log::debug!("Resolving {host}"); - Ok((host, port) - .to_socket_addrs()? - .next() - .ok_or(std::io::Error::other("Cannot resolve host"))?) + }; + + Ok(()) } diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 5c60808b..8b9379e2 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -5,28 +5,28 @@ use crate::config::Config; use crate::message::{check_encrypted, is_securejoin, recipient_matches_passthrough}; pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; -use crate::utils::extract_address; +use crate::utils::{build_resolver, extract_address}; use async_trait::async_trait; use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; +use hickory_resolver::TokioResolver; use mailparse::{MailHeaderMap, parse_mail}; -use std::net::SocketAddr; +use std::sync::Arc; /// Handler for outgoing SMTP messages. pub struct OutgoingBeforeQueueHandler { config: Config, - reinject_addr: SocketAddr, + dns_resolver: Arc, send_rate_limiter: DefaultKeyedRateLimiter, } impl OutgoingBeforeQueueHandler { pub fn new(config: Config) -> Result { - let reinject_addr = - crate::resolve_addr(&config.postfix_host, config.postfix_reinject_port)?; let quota = Quota::per_minute(config.max_user_send_per_minute) .allow_burst(config.max_user_send_burst_size); + let dns_resolver = Arc::new(build_resolver()?); Ok(Self { config, - reinject_addr, + dns_resolver, send_rate_limiter: RateLimiter::keyed(quota), }) } @@ -142,13 +142,20 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { log::debug!("Re-injecting the mail that passed checks"); - - crate::smtp_client::send(self.reinject_addr, envelope) - .await - .map_err(|e| { - log::warn!("Failed to re-inject mail: {}", e); - e.smtp_response() - })?; + let hostname = format!("[{}]", self.config.filtermail_host); + crate::smtp_client::send( + &self.config.postfix_host, + self.config.postfix_reinject_port, + envelope, + &hostname, + None, + self.dns_resolver.clone(), + ) + .await + .map_err(|e| { + log::warn!("Failed to re-inject mail: {}", e); + e.smtp_response() + })?; Ok(()) } diff --git a/filtermail/src/smtp_client.rs b/filtermail/src/smtp_client.rs index 64e9ad95..8ffb89e7 100644 --- a/filtermail/src/smtp_client.rs +++ b/filtermail/src/smtp_client.rs @@ -1,22 +1,173 @@ use crate::smtp_server::Envelope; -use std::net::SocketAddr; +use hickory_resolver::TokioResolver; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufStream}; -use tokio::net::TcpSocket; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::TcpStream; +use tokio::task::JoinSet; +use tokio_io_timeout::TimeoutStream; +use tokio_rustls::rustls::client::ClientSessionMemoryCache; + +/// A [`TcpStream`] used for SMTP communication. +#[expect(clippy::large_enum_variant)] +enum SmtpStream { + /// A plain TCP stream. + Plain(Pin>>), + /// A TLS-encrypted stream. + Tls(tokio_rustls::TlsStream>>>), +} + +impl SmtpStream { + /// Creates a new plain SMTP stream from a raw TCP stream, + /// with read and write timeouts set to 60 seconds. + fn plain(stream: TcpStream) -> Self { + let mut timeout_stream = TimeoutStream::new(stream); + timeout_stream.set_write_timeout(Some(Duration::from_secs(60))); + timeout_stream.set_read_timeout(Some(Duration::from_secs(60))); + Self::Plain(Box::pin(timeout_stream)) + } +} + +#[derive(Debug, Clone)] +pub struct TlsConfig { + pub(crate) allow_invalid_cert: bool, + pub(crate) session_cache: Arc, +} + +impl AsyncWrite for SmtpStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut() { + SmtpStream::Plain(stream) => Pin::new(stream).poll_write(cx, buf), + SmtpStream::Tls(stream) => Pin::new(stream).poll_write(cx, buf), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + SmtpStream::Plain(stream) => Pin::new(stream).poll_flush(cx), + SmtpStream::Tls(stream) => Pin::new(stream).poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + SmtpStream::Plain(stream) => Pin::new(stream).poll_shutdown(cx), + SmtpStream::Tls(stream) => Pin::new(stream).poll_shutdown(cx), + } + } +} + +impl AsyncRead for SmtpStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + SmtpStream::Plain(stream) => Pin::new(stream).poll_read(cx, buf), + SmtpStream::Tls(stream) => Pin::new(stream).poll_read(cx, buf), + } + } +} + +/// Converts address and port to a list of socket addresses. +/// +/// Performs non-blocking DNS resolution if address is a domain name, +/// or returns a single socket address if address is an IP. +async fn to_socket_addrs( + address: &str, + port: u16, + dns_resolver: Arc, +) -> Result, crate::error::Error> { + log::trace!("Resolving {address}..."); + if let Ok(ip) = address.parse() { + Ok(vec![std::net::SocketAddr::new(ip, port)]) + } else { + let lookup = dns_resolver.lookup_ip(address).await?; + Ok(lookup + .iter() + .map(|ip| std::net::SocketAddr::new(ip, port)) + .collect()) + } +} + +/// Establishes a TCP connection to the given address and port, trying all resolved IPs in parallel. +async fn establish_tcp_connection( + address: &str, + port: u16, + dns_resolver: Arc, +) -> Result { + let mut set: JoinSet> = JoinSet::new(); + + let socket_addrs = to_socket_addrs(address, port, dns_resolver).await?; + + for addr in socket_addrs.clone() { + set.spawn(async move { + log::trace!("SMTP client: connecting to {addr}..."); + let stream = + tokio::time::timeout(Duration::from_secs(60), TcpStream::connect(addr)).await??; + stream.set_nodelay(true)?; + Ok(stream) + }); + } + let mut stream: Option = None; + while let Some(result) = set.join_next().await { + match result { + Ok(Ok(s)) => { + stream = Some(s); + break; + } + Ok(Err(e)) => log::debug!("Failed to connect to socket: {e}"), + Err(e) => log::debug!("Failed to join task: {e}"), + } + } + + match stream { + Some(s) => Ok(s), + None => Err(crate::error::Error::ConnectionFailed( + socket_addrs.into_iter().map(|a| a.to_string()).collect(), + )), + } +} /// Sends an email using an SMTP server at `smtp_addr`. -pub async fn send(smtp_addr: SocketAddr, envelope: &Envelope) -> Result<(), crate::error::Error> { - let socket = TcpSocket::new_v4()?; +/// +/// If `tls_config` is provided, the connection will be upgraded to TLS. +/// The client will fail early if the server does not support STARTTLS. +/// +/// If `address` is a domain that resolves to multiple IP addresses, +/// all will be tried in parallel and the first successful connection will be used. +pub async fn send( + address: &str, + port: u16, + envelope: &Envelope, + client_hostname: &str, + tls_config: Option, + dns_resolver: Arc, +) -> Result<(), crate::error::Error> { + let stream = establish_tcp_connection(address, port, dns_resolver).await?; - // Disable Nagle's algorithm. - socket.set_nodelay(true)?; + log::debug!( + "SMTP client: successfully connected to {}", + stream.peer_addr()? + ); - let stream = socket.connect(smtp_addr).await?; - - let mut buf_stream = BufStream::new(stream); + let mut buf_stream = BufStream::new(SmtpStream::plain(stream)); let mut response = String::new(); macro_rules! smtp_write { ($command: expr) => { + log::trace!( + "SMTP client: sending: {}", + String::from_utf8_lossy($command) + ); buf_stream.write_all($command).await?; buf_stream.flush().await?; }; @@ -24,14 +175,24 @@ pub async fn send(smtp_addr: SocketAddr, envelope: &Envelope) -> Result<(), crat macro_rules! smtp_read { ($context:expr, $expected_code:expr) => { - buf_stream.read_line(&mut response).await?; + response.clear(); + let mut next_line = String::new(); + buf_stream.read_line(&mut next_line).await?; + response.push_str(&next_line); + while let Some(c) = next_line.as_bytes().get(3) + && *c == b'-' + { + next_line.clear(); + buf_stream.read_line(&mut next_line).await?; + response.push_str(&next_line); + } + log::trace!("SMTP response for {}:\n{}", $context, response); if !response.starts_with($expected_code) { return Err(crate::error::Error::MailSend { context: $context.to_string(), raw_smtp_answer: response.clone(), }); } - response.clear(); }; } @@ -45,8 +206,59 @@ pub async fn send(smtp_addr: SocketAddr, envelope: &Envelope) -> Result<(), crat // Read initial greeting smtp_read!("initial greeting", "220"); - // Greet (Using HELO as we don't want to deal with extended SMTP anyway.) - smtp_cmd!(b"HELO localhost\r\n", "HELO", "250"); + if tls_config.is_some() { + smtp_cmd!( + format!("EHLO {client_hostname}\r\n").as_bytes(), + "EHLO", + "250" + ); + } else { + smtp_cmd!( + format!("HELO {client_hostname}\r\n").as_bytes(), + "HELO", + "250" + ); + }; + + // STARTTLS + if let Some(tls_config) = tls_config { + if !response.to_uppercase().contains("STARTTLS") { + // TLS was requested, but server doesn't support STARTTLS. + return Err(crate::error::Error::MailSend { + context: "STARTTLS".to_string(), + raw_smtp_answer: response.clone(), + }); + } + + log::trace!("Initiating STARTTLS..."); + smtp_cmd!(b"STARTTLS\r\n", "STARTTLS", "220"); + + let stream = buf_stream.into_inner(); + let raw_tcp = match stream { + SmtpStream::Plain(s) => s, + SmtpStream::Tls(_) => { + unreachable!("This is the first and only place we upgrade to TLS.") + } + }; + + let tls_stream = crate::tls::wrap_rustls( + address, + raw_tcp, + tls_config.session_cache, + tls_config.allow_invalid_cert, + ) + .await?; + + let smtp_stream = SmtpStream::Tls(tls_stream); + + buf_stream = BufStream::new(smtp_stream); + + smtp_cmd!( + format!("EHLO {client_hostname}\r\n").as_bytes(), + "EHLO after STARTTLS", + "250" + ); + } // MAIL FROM smtp_cmd!( diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index ef7357a2..63f899ab 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -50,7 +50,7 @@ pub async fn run_smtp_server( addr: &impl tokio::net::ToSocketAddrs, handler: Arc, max_size: usize, -) -> Result<(), Box> +) -> Result<(), crate::error::Error> where H: SmtpHandler + 'static, { @@ -112,7 +112,12 @@ where if cmd.to_uppercase().starts_with("HELO") { writer.write_all(b"250-filtermail\r\n250 OK\r\n").await?; writer.flush().await?; - } else if cmd.to_uppercase().starts_with("EHLO") { + } else if cmd.to_uppercase().starts_with("EHLO") + // We support LMTP, but it's not validated; + // service that expects LMTP will send LMTP responses no matter the greeting. + // Sufficient for our internal use case. + || cmd.to_uppercase().starts_with("LHLO") + { writer .write_all(b"250-filtermail\r\n250-XFORWARD ADDR\r\n250 OK\r\n") .await?; diff --git a/filtermail/src/tls.rs b/filtermail/src/tls.rs new file mode 100644 index 00000000..978454a3 --- /dev/null +++ b/filtermail/src/tls.rs @@ -0,0 +1,46 @@ +//! TLS support. +use std::sync::Arc; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio_rustls::rustls::client::ClientSessionMemoryCache; +use tokio_rustls::{TlsStream, rustls}; + +mod danger; +use danger::NoCertificateVerification; + +pub async fn wrap_rustls( + hostname: &str, + stream: IO, + resumption_store: Arc, + dangerous_no_cert_verification: bool, +) -> Result, crate::error::Error> +where + IO: AsyncRead + AsyncWrite + Unpin, +{ + let mut root_cert_store = rustls::RootCertStore::empty(); + root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + + let mut config = rustls::ClientConfig::builder() + .with_root_certificates(root_cert_store) + .with_no_client_auth(); + + // Enable TLS 1.3 session resumption + // as defined in . + // + // Obsolete TLS 1.2 mechanisms defined in RFC 5246 + // and RFC 5077 have worse security + // and are not worth increasing + // attack surface: . + config.resumption = rustls::client::Resumption::store(resumption_store) + .tls12_resumption(rustls::client::Tls12Resumption::Disabled); + + if dangerous_no_cert_verification { + config + .dangerous() + .set_certificate_verifier(Arc::new(NoCertificateVerification::default())); + } + + let tls = tokio_rustls::TlsConnector::from(Arc::new(config)); + let name = rustls::pki_types::ServerName::try_from(hostname)?.to_owned(); + let tls_stream = tls.connect(name, stream).await?; + Ok(tls_stream.into()) +} diff --git a/filtermail/src/tls/danger.rs b/filtermail/src/tls/danger.rs new file mode 100644 index 00000000..1254fcf4 --- /dev/null +++ b/filtermail/src/tls/danger.rs @@ -0,0 +1,49 @@ +//! Dangerous TLS implementation of accepting invalid certificates for Rustls. + +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use tokio_rustls::rustls; + +#[derive(Debug, Default)] +pub(super) struct NoCertificateVerification(); + +impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + let provider = rustls::crypto::ring::default_provider(); + let supported_schemes = &provider.signature_verification_algorithms; + rustls::crypto::verify_tls12_signature(message, cert, dss, supported_schemes) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + let provider = rustls::crypto::ring::default_provider(); + let supported_schemes = &provider.signature_verification_algorithms; + rustls::crypto::verify_tls13_signature(message, cert, dss, supported_schemes) + } + + fn supported_verify_schemes(&self) -> Vec { + let provider = rustls::crypto::ring::default_provider(); + provider + .signature_verification_algorithms + .supported_schemes() + } +} diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs new file mode 100644 index 00000000..b13dde8e --- /dev/null +++ b/filtermail/src/transport.rs @@ -0,0 +1,227 @@ +use crate::config::Config; +use crate::smtp_client::TlsConfig; +use crate::smtp_server::{Envelope, SmtpHandler}; +use crate::utils::{AddressDomain, build_resolver}; +use async_trait::async_trait; +use hickory_resolver::TokioResolver; +use std::collections::BTreeMap; +use std::str::FromStr; +use std::sync::Arc; +use tokio::task::JoinSet; +use tokio_rustls::rustls; + +pub struct TransportHandler { + config: Config, + dns_resolver: Arc, + tls_resumption_store: Arc, +} + +impl TransportHandler { + pub fn new(config: Config) -> Result { + let dns_resolver = Arc::new(build_resolver()?); + let tls_resumption_store = Arc::new(rustls::client::ClientSessionMemoryCache::new(256)); + Ok(Self { + config, + dns_resolver, + tls_resumption_store, + }) + } + + /// Handles a single email transaction for a single recipient domain. + async fn handle_single_domain( + tls_resumption_store: Arc, + dns_resolver: Arc, + domain: AddressDomain, + envelope: Envelope, + client_hostname: String, + ) -> Result { + let mut allow_invalid_cert = false; + let mut skip_tls = false; + + let mx_hosts = match domain { + // no-DNS setup; assume the ip from email address is the destination. + AddressDomain::Literal(ip) => { + // We allow self-signed certs on IP-based relays. + allow_invalid_cert = true; + vec![(0, ip)] + } + AddressDomain::Name(mx_domain) => { + if mx_domain.eq_ignore_ascii_case("nauta.cu") { + // Special case; We don't want to defederate nauta.cu, + // which doesn't support STARTTLS at all. + skip_tls = true; + } else if mx_domain.starts_with('_') { + // We use domains starting with `_` for test deployments. + // (You can't request a non-wildcard cert for such domain) + allow_invalid_cert = true; + } + let query = format!("{mx_domain}."); + let mx_records = dns_resolver.mx_lookup(query).await.map_err(|e| { + if e.is_no_records_found() { + format!("512 No MX records for {mx_domain}") + } else if e.is_nx_domain() { + format!("512 Domain {mx_domain} does not exist") + } else { + format!("421 DNS resolution failed for {mx_domain}") + } + })?; + + let mut hosts: Vec<(u16, String)> = mx_records + .iter() + .map(|mx| { + let host = mx.exchange().to_string().trim_end_matches('.').to_string(); + (mx.preference(), host) + }) + .collect(); + hosts.sort(); + hosts + } + }; + + let tls_config = match skip_tls { + true => None, + false => Some(TlsConfig { + allow_invalid_cert, + session_cache: tls_resumption_store, + }), + }; + + // we try sequentially in order of MX preference, + // but the IPv4 and IPv6 connections (after `smtp_client::send` resolves mx hostname) + // happens in parallel. + 'try_relay: for (_, mx_host) in mx_hosts { + match crate::smtp_client::send( + &mx_host, + 25, + &envelope, + &client_hostname, + tls_config.clone(), + dns_resolver.clone(), + ) + .await + { + Ok(_) => { + return Ok("250 Ok".to_string()); + } + Err(error) => { + match error { + // We only want to try other MX hosts if we encounter a problem + // related to connection. + // (So we don't spam other servers if the message is actually rejected.) + crate::error::Error::Io(io_err) => { + log::warn!("I/O error relaying to mail server {mx_host}: {io_err}"); + continue 'try_relay; + } + crate::error::Error::ConnectionFailed(_) => { + log::warn!("Failed to connect to mail server {mx_host}: {error}"); + continue 'try_relay; + } + crate::error::Error::Tls(tls_err) => { + log::warn!("TLS error relaying to mail server {mx_host}: {tls_err}"); + continue 'try_relay; + } + _ => { + log::warn!("Message rejected by mail server {mx_host}: {error}"); + return Err(error.smtp_response()); + } + } + } + } + } + + Err("421 Failed to connect to any mail server".to_string()) + } +} + +#[async_trait] +impl SmtpHandler for TransportHandler { + /// NO-OP + fn handle_mail(&self, _: &str) -> Result<(), String> { + Ok(()) + } + + /// NO-OP + async fn check_data(&self, _: &mut Envelope) -> Result<(), String> { + Ok(()) + } + + /// NO-OP + async fn reinject_mail(&self, _: &Envelope) -> Result<(), String> { + Ok(()) + } + + /// Handles the DATA command and returns LMTP responses as single string. + /// + /// Never returns an error, as LMTP response is composite. + async fn handle_data(&self, envelope: &mut Envelope) -> Result { + let mut domain_rcpts_map = BTreeMap::new(); + + for rcpt in &envelope.rcpt_to { + let domain = AddressDomain::from_str(rcpt) + // Currently we cancel all transactions if any recipient address is invalid. + .map_err(|e| e.lmtp_response(envelope.rcpt_to.len()))?; + domain_rcpts_map + .entry(domain) + .or_insert_with(Vec::new) + .push(rcpt.to_string()); + } + + // one transaction per domain + let mut transactions = JoinSet::new(); + let mut task_id_domain_map = BTreeMap::new(); + + for (rcpt_domain, rcpts) in &domain_rcpts_map { + let domain_envelope = { + let mut envelope = envelope.clone(); + envelope.rcpt_to = rcpts.clone(); + envelope + }; + let task_id = transactions + .spawn(Self::handle_single_domain( + self.tls_resumption_store.clone(), + self.dns_resolver.clone(), + rcpt_domain.clone(), + domain_envelope, + self.config.mail_domain.clone(), + )) + .id(); + task_id_domain_map.insert(task_id, rcpt_domain); + } + + let mut rcpt_response_map = BTreeMap::new(); + while let Some(result) = transactions.join_next_with_id().await { + let domain_response = match result { + Ok((id, Ok(resp))) | Ok((id, Err(resp))) => { + task_id_domain_map.remove(&id).map(|domain| (domain, resp)) + } + Err(e) => { + log::error!("Failed to join task: {e}"); + task_id_domain_map + .remove(&e.id()) + .map(|domain| (domain, "451 Local error".to_string())) + } + }; + + if let Some((domain, smtp_response)) = domain_response + && let Some(rcpts) = domain_rcpts_map.get(domain) + { + for rcpt in rcpts { + rcpt_response_map.insert(rcpt, smtp_response.clone()); + } + } + } + + // compose lmtp response... + let ordered_responses: Vec = envelope + .rcpt_to + .iter() + .map(|rcpt| { + rcpt_response_map + .remove(rcpt) + .unwrap_or_else(|| "451 Local error".to_string()) + }) + .collect(); + + Ok(ordered_responses.join("\r\n")) + } +} diff --git a/filtermail/src/utils.rs b/filtermail/src/utils.rs index d9a0adfa..bf32acb7 100644 --- a/filtermail/src/utils.rs +++ b/filtermail/src/utils.rs @@ -1,3 +1,5 @@ +use hickory_resolver::TokioResolver; +use hickory_resolver::name_server::TokioConnectionProvider; use mailparse::MailAddr; use std::path::PathBuf; use std::str::FromStr; @@ -30,7 +32,7 @@ pub fn extract_address(input: &str) -> Option { /// Domain part of an email address, either a domain-literal (IP address in square brackets with /// optional protocol prefix) or a regular domain name. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord)] pub enum AddressDomain { /// Domain literal, e.g. /// - `192.0.2.0` in `test@[192.0.2.0]`, @@ -76,6 +78,15 @@ impl FromStr for AddressDomain { } } +impl AsRef for AddressDomain { + fn as_ref(&self) -> &str { + match self { + AddressDomain::Literal(literal) => literal.as_ref(), + AddressDomain::Name(name) => name.as_ref(), + } + } +} + /// Logs email to `/tmp/filtermail-rejected//.eml` /// and returns the file path. /// @@ -93,6 +104,23 @@ pub async fn log_eml(reason: &str, data: &[u8]) -> Result Result { + let dns_resolver = { + let mut builder = TokioResolver::builder(TokioConnectionProvider::default())?; + // https://github.com/hickory-dns/hickory-dns/issues/3519 + builder.options_mut().validate = true; + builder.build() + }; + + assert!( + dns_resolver.options().validate, + "incorrect resolver config: DNSSEC disabled; exiting" + ); + + Ok(dns_resolver) +} + #[cfg(test)] mod tests { use super::*; From 45eaa2f65978489838756a290454e6cc09e006d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:25:59 +0200 Subject: [PATCH 095/166] chore(release): prepare for 0.6.2 (#116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 14 ++++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index 862b9e95..56fd6962 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,17 @@ +## 0.6.2 - 2026-04-16 + +### Documentation + +- *(readme)* Reformat README.md (#115) + +### Features + +- Log disabled recipients (#113) +- *(transport)* Remote delivery over SMTP (#104) + +### Miscellaneous Tasks + +- *(dependabot)* Update configuration (#114) ## 0.6.1 - 2026-04-01 ### Documentation diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 23351923..b1a7e196 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -391,7 +391,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.6.1" +version = "0.6.2" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index e6a4db8e..beee9712 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.6.1" +version = "0.6.2" edition = "2024" license = "MIT" readme = "README.md" From dee00ebdcf2e870e9463ac0712b2c67e3f9e7a30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:37:05 +0200 Subject: [PATCH 096/166] perf(smtp-client): Cache connections (#117) --- filtermail/Cargo.lock | 137 +++++++++++++++++++++++++ filtermail/Cargo.toml | 1 + filtermail/src/inbound.rs | 4 + filtermail/src/outbound.rs | 4 + filtermail/src/smtp_client.rs | 186 +++++++++++++++++++++++----------- filtermail/src/transport.rs | 7 +- 6 files changed, 277 insertions(+), 62 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index b1a7e196..67b60c58 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -67,6 +67,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -155,6 +184,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -383,6 +421,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -402,6 +467,7 @@ dependencies = [ "lru", "mailparse", "parking_lot", + "retainer", "rstest", "serde", "serini", @@ -456,6 +522,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -587,6 +666,12 @@ 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 = "hickory-proto" version = "0.25.2" @@ -837,6 +922,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -986,6 +1077,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1057,6 +1154,20 @@ dependencies = [ "spki", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.13.0" @@ -1267,6 +1378,19 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "retainer" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b071fe646a2ab077f74656a4602c16528829c1fafa81946c5e88eaeccf08d5b" +dependencies = [ + "async-io", + "async-lock", + "futures-lite", + "log", + "rand 0.9.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -1339,6 +1463,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.38" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index beee9712..be87f3af 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -30,6 +30,7 @@ tokio-rustls = { version = "0.26.4", default-features = false, features = [ ] } webpki-roots = "1.0.6" tokio-io-timeout = "1.2.1" +retainer = "0.4.0" [dev-dependencies] rstest = "0.26.1" diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 7b9182e3..dc9dbf7b 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -4,6 +4,7 @@ use crate::ENCRYPTION_NEEDED_523; use crate::config::Config; use crate::dkim_verifier::DkimVerifier; use crate::message::{check_encrypted, is_securejoin}; +use crate::smtp_client::SmtpConnectionPool; pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; use crate::utils::{AddressDomain, build_resolver, extract_address, log_eml}; @@ -19,6 +20,7 @@ pub struct IncomingBeforeQueueHandler { dns_resolver: Arc, dkim_verifier: DkimVerifier, skip_dkim: bool, + smtp_connection_pool: Arc, } impl IncomingBeforeQueueHandler { @@ -29,6 +31,7 @@ impl IncomingBeforeQueueHandler { dns_resolver: dns_resolver.clone(), dkim_verifier: DkimVerifier::new(dns_resolver), skip_dkim, + smtp_connection_pool: SmtpConnectionPool::new(), }) } @@ -152,6 +155,7 @@ impl SmtpHandler for IncomingBeforeQueueHandler { &hostname, None, self.dns_resolver.clone(), + self.smtp_connection_pool.clone(), ) .await .map_err(|e| { diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 8b9379e2..6b6adba0 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -3,6 +3,7 @@ use crate::ENCRYPTION_NEEDED_523; use crate::config::Config; use crate::message::{check_encrypted, is_securejoin, recipient_matches_passthrough}; +use crate::smtp_client::SmtpConnectionPool; pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; use crate::utils::{build_resolver, extract_address}; @@ -17,6 +18,7 @@ pub struct OutgoingBeforeQueueHandler { config: Config, dns_resolver: Arc, send_rate_limiter: DefaultKeyedRateLimiter, + smtp_connection_pool: Arc, } impl OutgoingBeforeQueueHandler { @@ -28,6 +30,7 @@ impl OutgoingBeforeQueueHandler { config, dns_resolver, send_rate_limiter: RateLimiter::keyed(quota), + smtp_connection_pool: SmtpConnectionPool::new(), }) } } @@ -150,6 +153,7 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { &hostname, None, self.dns_resolver.clone(), + self.smtp_connection_pool.clone(), ) .await .map_err(|e| { diff --git a/filtermail/src/smtp_client.rs b/filtermail/src/smtp_client.rs index 8ffb89e7..b35c1b4b 100644 --- a/filtermail/src/smtp_client.rs +++ b/filtermail/src/smtp_client.rs @@ -7,13 +7,62 @@ use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufStream}; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio::net::TcpStream; -use tokio::task::JoinSet; +use tokio::task::{JoinHandle, JoinSet}; use tokio_io_timeout::TimeoutStream; use tokio_rustls::rustls::client::ClientSessionMemoryCache; +/// A connection pool for SMTP connections, keyed by (address, port). +/// +/// Connections are cached for up to 100 seconds of idle time. +/// +/// Only a single connection is cached per address/port pair. +pub struct SmtpConnectionPool { + pool: Arc>>, + monitor_handle: JoinHandle<()>, +} + +impl SmtpConnectionPool { + /// Creates a new connection pool and starts the cache monitoring task. + pub fn new() -> Arc { + let pool = Arc::new(retainer::Cache::new()); + let pool_clone = pool.clone(); + + let monitor_handle = + tokio::spawn(async move { pool_clone.monitor(4, 0.25, Duration::from_secs(10)).await }); + + Arc::new(Self { + pool, + monitor_handle, + }) + } + + /// Takes a connection from the pool for the given address and port, if available. + pub async fn take(&self, address: &str, port: u16) -> Option> { + self.pool.remove(&(address.to_string(), port)).await + } + + /// Puts a connection into the pool for the given address and port, with a 100s timeout. + pub async fn put(&self, address: &str, port: u16, stream: BufStream) { + // similarly to postfix default -> 100s max idle time. + self.pool + .insert( + (address.to_string(), port), + stream, + Duration::from_secs(100), + ) + .await; + } +} + +impl Drop for SmtpConnectionPool { + fn drop(&mut self) { + self.monitor_handle.abort(); + } +} + /// A [`TcpStream`] used for SMTP communication. #[expect(clippy::large_enum_variant)] -enum SmtpStream { +pub enum SmtpStream { /// A plain TCP stream. Plain(Pin>>), /// A TLS-encrypted stream. @@ -144,6 +193,8 @@ async fn establish_tcp_connection( /// /// If `address` is a domain that resolves to multiple IP addresses, /// all will be tried in parallel and the first successful connection will be used. +/// +/// `pool` is used to reuse existing connections to the same address and port, if available. pub async fn send( address: &str, port: u16, @@ -151,23 +202,30 @@ pub async fn send( client_hostname: &str, tls_config: Option, dns_resolver: Arc, + pool: Arc, ) -> Result<(), crate::error::Error> { - let stream = establish_tcp_connection(address, port, dns_resolver).await?; + let (mut buf_stream, reused) = if let Some(stream) = pool.take(address, port).await { + log::debug!("Reusing existing connection to {address}:{port}",); + if tls_config.is_some() { + // This should never happen, + // assert to make sure we never accidentally use a plain connection while expecting TLS. + assert!( + matches!(stream.get_ref(), SmtpStream::Tls(_)), + "Expected TLS stream from pool, but got plain stream." + ); + } + (stream, true) + } else { + let stream = establish_tcp_connection(address, port, dns_resolver).await?; + log::debug!("Successfully connected to {}", stream.peer_addr()?); + (BufStream::new(SmtpStream::plain(stream)), false) + }; - log::debug!( - "SMTP client: successfully connected to {}", - stream.peer_addr()? - ); - - let mut buf_stream = BufStream::new(SmtpStream::plain(stream)); let mut response = String::new(); macro_rules! smtp_write { ($command: expr) => { - log::trace!( - "SMTP client: sending: {}", - String::from_utf8_lossy($command) - ); + log::trace!("Sending: {}", String::from_utf8_lossy($command)); buf_stream.write_all($command).await?; buf_stream.flush().await?; }; @@ -203,61 +261,65 @@ pub async fn send( }; } - // Read initial greeting - smtp_read!("initial greeting", "220"); - - if tls_config.is_some() { - smtp_cmd!( - format!("EHLO {client_hostname}\r\n").as_bytes(), - "EHLO", - "250" - ); + if reused { + smtp_cmd!(b"RSET\r\n", "RSET on reused connection", "250"); } else { - smtp_cmd!( - format!("HELO {client_hostname}\r\n").as_bytes(), - "HELO", - "250" - ); - }; + // Read initial greeting + smtp_read!("initial greeting", "220"); - // STARTTLS - if let Some(tls_config) = tls_config { - if !response.to_uppercase().contains("STARTTLS") { - // TLS was requested, but server doesn't support STARTTLS. - return Err(crate::error::Error::MailSend { - context: "STARTTLS".to_string(), - raw_smtp_answer: response.clone(), - }); - } - - log::trace!("Initiating STARTTLS..."); - smtp_cmd!(b"STARTTLS\r\n", "STARTTLS", "220"); - - let stream = buf_stream.into_inner(); - let raw_tcp = match stream { - SmtpStream::Plain(s) => s, - SmtpStream::Tls(_) => { - unreachable!("This is the first and only place we upgrade to TLS.") - } + if tls_config.is_some() { + smtp_cmd!( + format!("EHLO {client_hostname}\r\n").as_bytes(), + "EHLO", + "250" + ); + } else { + smtp_cmd!( + format!("HELO {client_hostname}\r\n").as_bytes(), + "HELO", + "250" + ); }; - let tls_stream = crate::tls::wrap_rustls( - address, - raw_tcp, - tls_config.session_cache, - tls_config.allow_invalid_cert, - ) - .await?; + // STARTTLS + if let Some(tls_config) = tls_config { + if !response.to_uppercase().contains("STARTTLS") { + // TLS was requested, but server doesn't support STARTTLS. + return Err(crate::error::Error::MailSend { + context: "STARTTLS".to_string(), + raw_smtp_answer: response.clone(), + }); + } - let smtp_stream = SmtpStream::Tls(tls_stream); + log::trace!("Initiating STARTTLS..."); + smtp_cmd!(b"STARTTLS\r\n", "STARTTLS", "220"); - buf_stream = BufStream::new(smtp_stream); + let stream = buf_stream.into_inner(); + let raw_tcp = match stream { + SmtpStream::Plain(s) => s, + SmtpStream::Tls(_) => { + unreachable!("This is the first and only place we upgrade to TLS.") + } + }; - smtp_cmd!( - format!("EHLO {client_hostname}\r\n").as_bytes(), - "EHLO after STARTTLS", - "250" - ); + let tls_stream = crate::tls::wrap_rustls( + address, + raw_tcp, + tls_config.session_cache, + tls_config.allow_invalid_cert, + ) + .await?; + + let smtp_stream = SmtpStream::Tls(tls_stream); + + buf_stream = BufStream::new(smtp_stream); + + smtp_cmd!( + format!("EHLO {client_hostname}\r\n").as_bytes(), + "EHLO after STARTTLS", + "250" + ); + } } // MAIL FROM @@ -282,5 +344,7 @@ pub async fn send( smtp_write!(b".\r\n"); smtp_read!("end of DATA", "250"); + pool.put(address, port, buf_stream).await; + Ok(()) } diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs index b13dde8e..12f978fc 100644 --- a/filtermail/src/transport.rs +++ b/filtermail/src/transport.rs @@ -1,5 +1,5 @@ use crate::config::Config; -use crate::smtp_client::TlsConfig; +use crate::smtp_client::{SmtpConnectionPool, TlsConfig}; use crate::smtp_server::{Envelope, SmtpHandler}; use crate::utils::{AddressDomain, build_resolver}; use async_trait::async_trait; @@ -14,6 +14,7 @@ pub struct TransportHandler { config: Config, dns_resolver: Arc, tls_resumption_store: Arc, + smtp_connection_pool: Arc, } impl TransportHandler { @@ -24,12 +25,14 @@ impl TransportHandler { config, dns_resolver, tls_resumption_store, + smtp_connection_pool: SmtpConnectionPool::new(), }) } /// Handles a single email transaction for a single recipient domain. async fn handle_single_domain( tls_resumption_store: Arc, + smtp_connection_pool: Arc, dns_resolver: Arc, domain: AddressDomain, envelope: Envelope, @@ -97,6 +100,7 @@ impl TransportHandler { &client_hostname, tls_config.clone(), dns_resolver.clone(), + smtp_connection_pool.clone(), ) .await { @@ -179,6 +183,7 @@ impl SmtpHandler for TransportHandler { let task_id = transactions .spawn(Self::handle_single_domain( self.tls_resumption_store.clone(), + self.smtp_connection_pool.clone(), self.dns_resolver.clone(), rcpt_domain.clone(), domain_envelope, From e156ce9125658c10aeeb5aafdbe3c02997cb879e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:39:00 +0200 Subject: [PATCH 097/166] chore(deps): Bump taiki-e/install-action from 2.71.2 to 2.75.5 (#120) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.71.2 to 2.75.5. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/d858f8113943481093e02986a7586a4819a3bfd6...7a4939c09608b2a1986b484eca1d16fd0db8ebef) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.75.5 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index bcca9365..c8522bbc 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - - uses: taiki-e/install-action@d858f8113943481093e02986a7586a4819a3bfd6 # v2 + - uses: taiki-e/install-action@7a4939c09608b2a1986b484eca1d16fd0db8ebef # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index c3f334ee..c996ef65 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@d858f8113943481093e02986a7586a4819a3bfd6 # v2 + - uses: taiki-e/install-action@7a4939c09608b2a1986b484eca1d16fd0db8ebef # v2 with: tool: cargo-zigbuild - name: Build From 0e1b7173abc7ddd5739ad16800547e259040fceb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:39:24 +0200 Subject: [PATCH 098/166] chore(deps): Bump actions/upload-artifact from 7.0.0 to 7.0.1 (#119) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.0 to 7.0.1. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/bbbca2ddaa5d8feaa63e36b76fdaad77386f024f...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index c996ef65..e1e6e8db 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -26,13 +26,13 @@ jobs: --target x86_64-unknown-linux-musl \ --target aarch64-unknown-linux-musl - name: Upload x86_64 binary - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: filtermail-x86_64 path: target/x86_64-unknown-linux-musl/release/filtermail if-no-files-found: error - name: Upload aarch64 binary - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: filtermail-aarch64 path: target/aarch64-unknown-linux-musl/release/filtermail From 1e356315da8dfab9232ea2bb94bfa3d603746f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:03:49 +0200 Subject: [PATCH 099/166] feat(smtp-server): Log malformed SMTP commands (#118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #110 Signed-off-by: Jagoda Ślązak --- filtermail/src/smtp_server.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index 63f899ab..1a7f8178 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -1,6 +1,6 @@ //! A simplified SMTP server implementation for internal communication. -use crate::utils::extract_address; +use crate::utils::{extract_address, log_eml}; use async_trait::async_trait; use std::sync::Arc; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; @@ -103,7 +103,9 @@ where // Note: this will kill the connection if any line doesn't end with CRLF. // This is intentional as stray LF most likely means an attempt to exploit the server. let Some(cmd) = line.strip_suffix("\r\n") else { - log::warn!("Malformed command without CRLF ending! Closing connection."); + log::warn!( + "Malformed command without CRLF ending! Received: {line:?} Closing connection." + ); break 'connection; }; @@ -171,6 +173,15 @@ where if !data_line.ends_with("\r\n") { log::warn!("Malformed DATA line without CRLF ending! Closing connection."); + data.extend_from_slice(data_line.as_bytes()); + let eml_path = log_eml("malformed-data", &data) + .await + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|e| { + log::error!("Failed to save rejected message to file: {e}"); + "ERR".to_string() + }); + log::info!("Rejected message stored at: {eml_path}"); break 'connection; } From 3eb3b86bc9927ee7ee3a7bc67ab92d8b1ce85b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:09:53 +0200 Subject: [PATCH 100/166] chore(release): prepare for 0.6.3 (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 9 +++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index 56fd6962..1e5d38ef 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,12 @@ +## 0.6.3 - 2026-04-20 + +### Features + +- *(smtp-server)* Log malformed SMTP commands (#118) + +### Performance + +- *(smtp-client)* Cache connections (#117) ## 0.6.2 - 2026-04-16 ### Documentation diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 67b60c58..6ef1ca7b 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -456,7 +456,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.6.2" +version = "0.6.3" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index be87f3af..997ebb9c 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.6.2" +version = "0.6.3" edition = "2024" license = "MIT" readme = "README.md" From 17ccd3e2caff5cdfab663c5a8496c271f94d159b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:36:40 +0200 Subject: [PATCH 101/166] chore(deps): Bump bnjbvr/cargo-machete from 0.9.1 to 0.9.2 (#124) Bumps [bnjbvr/cargo-machete](https://github.com/bnjbvr/cargo-machete) from 0.9.1 to 0.9.2. - [Release notes](https://github.com/bnjbvr/cargo-machete/releases) - [Changelog](https://github.com/bnjbvr/cargo-machete/blob/main/CHANGELOG.md) - [Commits](https://github.com/bnjbvr/cargo-machete/compare/7959c845782fed02ee69303126d4a12d64f1db18...ac30a525c0a8d163a92d727b3ff079ee3f6ecb08) --- updated-dependencies: - dependency-name: bnjbvr/cargo-machete dependency-version: 0.9.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index c8522bbc..db77b2c1 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: bnjbvr/cargo-machete@7959c845782fed02ee69303126d4a12d64f1db18 # v0.9.1 + - uses: bnjbvr/cargo-machete@ac30a525c0a8d163a92d727b3ff079ee3f6ecb08 # v0.9.2 lint-clippy: name: Check Clippy From c1a4d99d2ebb5b0b9ef445148fd462026a09aa0c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:36:58 +0200 Subject: [PATCH 102/166] chore(deps): Bump taiki-e/install-action from 2.75.5 to 2.75.17 (#125) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.5 to 2.75.17. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/7a4939c09608b2a1986b484eca1d16fd0db8ebef...58e862542551f667fa44c8a2a4a1d64ad477c96a) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.75.17 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index db77b2c1..c8a10933 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - - uses: taiki-e/install-action@7a4939c09608b2a1986b484eca1d16fd0db8ebef # v2 + - uses: taiki-e/install-action@58e862542551f667fa44c8a2a4a1d64ad477c96a # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index e1e6e8db..842913bc 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@7a4939c09608b2a1986b484eca1d16fd0db8ebef # v2 + - uses: taiki-e/install-action@58e862542551f667fa44c8a2a4a1d64ad477c96a # v2 with: tool: cargo-zigbuild - name: Build From 21aaea0d92f03b1505674d78a046672a7d9a0d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Mon, 27 Apr 2026 14:44:21 +0200 Subject: [PATCH 103/166] fix: Implement missing "implicit MX" rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements "implicit MX" as described by https://datatracker.ietf.org/doc/html/rfc5321#section-5.1 Fixes: #126 Signed-off-by: Jagoda Ślązak --- filtermail/src/transport.rs | 44 ++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs index 12f978fc..d13deef5 100644 --- a/filtermail/src/transport.rs +++ b/filtermail/src/transport.rs @@ -59,25 +59,33 @@ impl TransportHandler { allow_invalid_cert = true; } let query = format!("{mx_domain}."); - let mx_records = dns_resolver.mx_lookup(query).await.map_err(|e| { - if e.is_no_records_found() { - format!("512 No MX records for {mx_domain}") - } else if e.is_nx_domain() { - format!("512 Domain {mx_domain} does not exist") - } else { - format!("421 DNS resolution failed for {mx_domain}") - } - })?; - let mut hosts: Vec<(u16, String)> = mx_records - .iter() - .map(|mx| { - let host = mx.exchange().to_string().trim_end_matches('.').to_string(); - (mx.preference(), host) - }) - .collect(); - hosts.sort(); - hosts + match dns_resolver.mx_lookup(query).await { + Ok(mx_records) => { + let mut hosts: Vec<(u16, String)> = mx_records + .iter() + .map(|mx| { + let host = + mx.exchange().to_string().trim_end_matches('.').to_string(); + (mx.preference(), host) + }) + .collect(); + hosts.sort(); + hosts + } + Err(e) => { + if e.is_no_records_found() { + // "implicit MX" as described by section 5.1 of RFC5321 + // https://datatracker.ietf.org/doc/html/rfc5321#section-5.1 + log::debug!("No MX record found, using implicit MX: {mx_domain}"); + vec![(0, mx_domain)] + } else if e.is_nx_domain() { + return Err(format!("512 Domain {mx_domain} does not exist")); + } else { + return Err(format!("421 DNS resolution failed for {mx_domain}")); + } + } + } } }; From 1b1585eb7d7e1579b9b1d34da63a8092fbec8fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Tue, 28 Apr 2026 08:24:27 +0200 Subject: [PATCH 104/166] refactor(transport): Explicitly handle RFC7505 null MX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/src/transport.rs | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs index d13deef5..2e039a25 100644 --- a/filtermail/src/transport.rs +++ b/filtermail/src/transport.rs @@ -62,14 +62,23 @@ impl TransportHandler { match dns_resolver.mx_lookup(query).await { Ok(mx_records) => { - let mut hosts: Vec<(u16, String)> = mx_records - .iter() - .map(|mx| { - let host = - mx.exchange().to_string().trim_end_matches('.').to_string(); - (mx.preference(), host) - }) - .collect(); + let mut hosts: Vec<(u16, String)> = Vec::new(); + for mx in mx_records { + // Null MX / RFC7505 + if mx.exchange().is_root() { + // From RFC7505 section 3: + // > A domain that advertises a null MX MUST NOT + // > advertise any other MX RR. + // We assume this is the only record and exit early. + return Err( + "556 5.1.10 Permanent failure: Recipient address has null MX" + .to_string(), + ); + } + + let host = mx.exchange().to_string().trim_end_matches('.').to_string(); + hosts.push((mx.preference(), host)) + } hosts.sort(); hosts } From 0a652e61ee67f083717ece6afa8a0f16c992b805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Fri, 1 May 2026 10:30:09 +0200 Subject: [PATCH 105/166] feat: https transport channel (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transport mode: Implements an additional mail delivery channel over HTTPS. Incoming mode: Adds a http server listening for incoming messages delivered over HTTPS. Signed-off-by: Jagoda Ślązak --- filtermail/Cargo.lock | 178 ++++++++++++++++++++++++++++++++++ filtermail/Cargo.toml | 15 +++ filtermail/README.md | 76 ++++++++++++++- filtermail/src/config.rs | 6 ++ filtermail/src/error.rs | 6 ++ filtermail/src/http_server.rs | 150 ++++++++++++++++++++++++++++ filtermail/src/main.rs | 13 +++ filtermail/src/tls.rs | 20 ++-- filtermail/src/transport.rs | 175 ++++++++++++++++++++++++++++++++- 9 files changed, 627 insertions(+), 12 deletions(-) create mode 100644 filtermail/src/http_server.rs diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 6ef1ca7b..122f887d 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -107,6 +107,12 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" @@ -463,6 +469,10 @@ dependencies = [ "env_logger", "governor", "hickory-resolver", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", "log", "lru", "mailparse", @@ -486,6 +496,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" @@ -643,6 +659,25 @@ dependencies = [ "web-time", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -721,6 +756,109 @@ dependencies = [ "tracing", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -867,6 +1005,12 @@ 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]] name = "jiff" version = "0.2.23" @@ -1821,6 +1965,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -1851,6 +2008,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1882,6 +2045,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.19.0" @@ -1973,6 +2142,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 997ebb9c..69ae0b94 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -31,6 +31,21 @@ tokio-rustls = { version = "0.26.4", default-features = false, features = [ webpki-roots = "1.0.6" tokio-io-timeout = "1.2.1" retainer = "0.4.0" +hyper = { version = "1.8.1", features = ["server"] } +hyper-util = { version = "0.1.20", features = [ + "tokio", + "server", + "server-auto", + "client-legacy", +] } +http-body-util = "0.1.3" +hyper-rustls = { version = "0.27.9", default-features = false, features = [ + "ring", + "http1", + "http2", + "logging", + "tls12", +] } [dev-dependencies] rstest = "0.26.1" diff --git a/filtermail/README.md b/filtermail/README.md index 500bdaed..e3b14337 100644 --- a/filtermail/README.md +++ b/filtermail/README.md @@ -19,6 +19,29 @@ Filtermail can be used in `incoming`, `outgoing` or `transport` mode. ### Incoming mode +```mermaid +flowchart LR + + subgraph chatmail relay + subgraph postfix + smtpd1[smtpd] + smtpd2[smtpd] --> queue[...] + end + nginx[nginx] + smtpd1 -.SMTP :10081.-> filtermail[filtermail-incoming] + nginx -.HTTP :10082.-> filtermail + filtermail --SMTP :10026--> smtpd2 + end + mta[Sender's relay] -.SMTP :25.-> smtpd1 + mta -.HTTPS /mxdeliv.-> nginx + + style postfix fill:#363 + style smtpd1 fill:#252 + style smtpd2 fill:#252 + style queue fill:#252 + style filtermail fill:#225 +``` + Filtermail in incoming mode acts as a proxy filter for messages received from remote MTAs and performs following steps: @@ -41,8 +64,32 @@ for messages received from remote MTAs and performs following steps: 5. In case of a DKIM failure, the message is saved to `/tmp/filtermail-rejected/dkim-verify` directory for later inspection. +In contrast to outgoing mode, incoming mode starts with not only SMTP but also HTTP listener. +Built-in HTTP server doesn't handle TLS, and should be placed behind a TLS-terminating reverse proxy. + ### Outgoing mode +```mermaid +flowchart LR + + subgraph chatmail relay + subgraph postfix + smtpd1[smtpd] + smtpd2[smtpd] --> queue[...] + end + smtpd1 --SMTP :10080--> filtermail[filtermail-outgoing] + filtermail --SMTP :10025--> smtpd2 + open-dkim[OpenDKIM] <--milter--> smtpd2 + end + client[Client] --SMTP :587--> smtpd1 + + style postfix fill:#363 + style smtpd1 fill:#252 + style smtpd2 fill:#252 + style queue fill:#252 + style filtermail fill:#225 +``` + Filtermail in outgoing mode acts as a proxy filter for messages received from clients and performs following steps: @@ -58,12 +105,33 @@ for messages received from clients and performs following steps: ### Transport mode +```mermaid +flowchart LR + + subgraph chatmail relay + subgraph postfix + qmgr[...] --> lmtp[default-transport lmtp] + end + + lmtp --LMTP :10083--> filtermail[filtermail-transport] + end + filtermail -.SMTP :25.-> mta[Recipient's relay] + filtermail -.HTTPS /mxdeliv.-> mta + + style postfix fill:#363 + style qmgr fill:#252 + style lmtp fill:#252 + style filtermail fill:#225 +``` + Filtermail in transport mode is used for final delivery to remote MTAs. As opposed to incoming/outgoing, it accepts connections from postfix over LMTP instead of SMTP, to allow returning per-recipient status back to postfix. -Received message is split per-domain and sent to recipients' MX servers over SMTP, +Received message is split per-domain and sent to recipients' MX servers over HTTP and SMTP, enforcing TLS. As opposed to postfix, IPv4 and IPv6 connections are tried in parallel and first successful connection is used. +HTTP delivery channel is preferred, +and SMTP is used only if HTTP delivery fails. ## Configuration @@ -74,8 +142,10 @@ but implements a custom parser that only requires a small subset of configuratio - `filtermail_smtp_port` - port to listen on in outgoing mode, defaults to `10080`. -- `filtermail_smtp_port_incoming` - port to listen on in incoming mode, +- `filtermail_smtp_port_incoming` - SMTP port to listen on in incoming mode, defaults to `10081`. +- `filtermail_http_port_incoming` - HTTP port to listen on in incoming mode, + defaults to `10082`. - `filtermail_lmtp_port_transport` - port to listen on in transport mode, defaults to `10083`. - `postfix_reinject_port` - port to reinject messages to postfix in outgoing mode, @@ -124,7 +194,7 @@ Additional options that can be set using environment variables: Although unsupported, it may still work outside of this context or even without postfix, with few considerations: -- Filtermail expects to receive messages from a trusted server, +- Filtermail expects to receive messages from trusted clients, and thus should not listen on ports exposed directly to the internet. - Issues outside of chatmail relay context are not necessarily considered bugs; PRs fixing them are not guaranteed to be accepted. diff --git a/filtermail/src/config.rs b/filtermail/src/config.rs index 71a9bc18..4707d356 100644 --- a/filtermail/src/config.rs +++ b/filtermail/src/config.rs @@ -14,6 +14,8 @@ pub struct Config { pub filtermail_smtp_port: u16, #[serde(default = "Config::default_filtermail_smtp_port_incoming")] pub filtermail_smtp_port_incoming: u16, + #[serde(default = "Config::default_filtermail_http_port_incoming")] + pub filtermail_http_port_incoming: u16, #[serde(default = "Config::default_filtermail_lmtp_port_transport")] pub filtermail_lmtp_port_transport: u16, #[serde(default = "Config::default_postfix_host")] @@ -110,6 +112,9 @@ impl Config { const fn default_filtermail_smtp_port_incoming() -> u16 { 10081 } + const fn default_filtermail_http_port_incoming() -> u16 { + 10082 + } const fn default_filtermail_lmtp_port_transport() -> u16 { 10083 } @@ -143,6 +148,7 @@ impl Default for Config { filtermail_host: Self::default_filtermail_host(), filtermail_smtp_port: Self::default_filtermail_smtp_port(), filtermail_smtp_port_incoming: Self::default_filtermail_smtp_port_incoming(), + filtermail_http_port_incoming: Self::default_filtermail_http_port_incoming(), filtermail_lmtp_port_transport: Self::default_filtermail_lmtp_port_transport(), postfix_host: Self::default_postfix_host(), postfix_reinject_port: Self::default_postfix_reinject_port(), diff --git a/filtermail/src/error.rs b/filtermail/src/error.rs index 32bea648..d329bbdd 100644 --- a/filtermail/src/error.rs +++ b/filtermail/src/error.rs @@ -27,6 +27,12 @@ pub enum Error { Tls(#[from] rustls::Error), #[error(transparent)] InvalidDnsName(#[from] rustls::pki_types::InvalidDnsNameError), + #[error(transparent)] + Hyper(#[from] hyper::Error), + #[error(transparent)] + HyperHttp(#[from] hyper::http::Error), + #[error(transparent)] + HyperClient(#[from] hyper_util::client::legacy::Error), } impl Error { diff --git a/filtermail/src/http_server.rs b/filtermail/src/http_server.rs new file mode 100644 index 00000000..1e943ddf --- /dev/null +++ b/filtermail/src/http_server.rs @@ -0,0 +1,150 @@ +use crate::smtp_server::{Envelope, SmtpHandler}; +use http_body_util::combinators::BoxBody; +use http_body_util::{BodyExt, Full}; +use hyper::body::{Bytes, Incoming}; +use hyper::service::Service; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use std::convert::Infallible; +use std::pin::Pin; +use std::sync::Arc; +use tokio::net::{TcpListener, TcpStream}; + +/// Runs the HTTP server on the specified address with the given handler and maximum message size. +pub async fn run_http_server( + addr: &impl tokio::net::ToSocketAddrs, + handler: Arc, + max_size: usize, +) -> Result<(), crate::error::Error> +where + H: SmtpHandler + 'static, +{ + let listener = TcpListener::bind(addr).await?; + loop { + let (socket, _) = listener.accept().await?; + + // Disable Nagle's algorithm. + socket.set_nodelay(true)?; + + let handler = handler.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(socket, handler, max_size).await { + log::error!("Error handling connection: {e}"); + } + }); + } +} + +/// Handles a single HTTP connection. +async fn handle_connection( + socket: TcpStream, + handler: Arc, + max_size: usize, +) -> Result<(), String> +where + H: SmtpHandler + 'static, +{ + let service = MxDelivService::new(handler, max_size); + + hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new()) + .serve_connection(TokioIo::new(socket), service) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +struct MxDelivService { + handler: Arc, + max_size: usize, +} + +impl MxDelivService { + /// Creates a new [`MxDelivService`]. + fn new(handler: Arc, max_size: usize) -> Self { + Self { handler, max_size } + } +} + +impl Service> for MxDelivService { + type Response = Response>; + type Error = crate::error::Error; + type Future = Pin> + Send>>; + + fn call(&self, req: Request) -> Self::Future { + let handler = self.handler.clone(); + let max_size = self.max_size; + + let fut = async move { + if req.method() != hyper::Method::POST { + return Ok(Response::builder().status(405).body( + // This is client's implementation error if it happens, + // so we don't care about sending a proper smtp response. + Full::new(Bytes::from("Method Not Allowed")).boxed(), + )?); + } + + let mail_from = req + .headers() + .get(crate::transport::HEADER_MAIL_FROM) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + match handler.handle_mail(&mail_from) { + Ok(_) => {} + Err(e) => { + return Ok(Response::builder() + .status(400) + .body(Full::new(Bytes::from(e)).boxed())?); + } + }; + + let rcpt_to = req + .headers() + .get_all(crate::transport::HEADER_RCPT_TO) + .iter() + .filter_map(|v| v.to_str().ok()) + .map(ToString::to_string) + .collect(); + + let body_limited = http_body_util::Limited::new(req.into_body(), max_size); + let body_bytes = match body_limited.collect().await { + Ok(body) => body.to_bytes(), + Err(_) => { + return Ok(Response::builder().status(413).body( + Full::new(Bytes::from("552 Message exceeds maximum size")).boxed(), + )?); + } + }; + + let mut envelope = Envelope { + origin_ip: "".to_string(), + mail_from, + rcpt_to, + data: body_bytes.to_vec(), + }; + + log::debug!("(HTTP) MAIL FROM:<{}>", envelope.mail_from); + for rcpt in &envelope.rcpt_to { + log::debug!("(HTTP) RCPT TO:<{}>", rcpt); + } + + log::trace!( + "(HTTP) DATA:\n{:?}", + String::from_utf8_lossy(&envelope.data) + ); + + match handler.handle_data(&mut envelope).await { + Ok(response) => Ok(Response::builder() + .status(201) + .body(Full::new(Bytes::from(response)).boxed())?), + Err(e) => Ok(Response::builder() + .status(400) + .body(Full::new(Bytes::from(e)).boxed())?), + } + }; + + Box::pin(fut) + } +} diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index 47d2ed45..072e559c 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -27,6 +27,7 @@ mod config; mod dkim_verifier; pub(crate) mod error; +mod http_server; pub(crate) mod inbound; pub(crate) mod message; pub(crate) mod openpgp; @@ -37,6 +38,7 @@ mod tls; mod transport; pub(crate) mod utils; +use crate::http_server::run_http_server; use crate::transport::TransportHandler; use config::Config; use env_logger::Env; @@ -153,6 +155,17 @@ async fn main() -> Result<(), error::Error> { addr_smtp.1 ); + let addr_http = (config.filtermail_host, config.filtermail_http_port_incoming); + let handler_http = handler.clone(); + + server_set + .spawn(async move { run_http_server(&addr_http, handler_http, max_size).await }); + log::debug!( + "Incoming HTTP server listening on {}:{}", + addr_http.0, + addr_http.1 + ); + while let Some(result) = server_set.join_next().await { if let Err(e) = result { eprintln!("Server error: {}", e); diff --git a/filtermail/src/tls.rs b/filtermail/src/tls.rs index 978454a3..bb13de0d 100644 --- a/filtermail/src/tls.rs +++ b/filtermail/src/tls.rs @@ -16,8 +16,19 @@ pub async fn wrap_rustls( where IO: AsyncRead + AsyncWrite + Unpin, { - let mut root_cert_store = rustls::RootCertStore::empty(); - root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let config = configure_rustls(resumption_store, dangerous_no_cert_verification); + let tls = tokio_rustls::TlsConnector::from(Arc::new(config)); + let name = rustls::pki_types::ServerName::try_from(hostname)?.to_owned(); + let tls_stream = tls.connect(name, stream).await?; + Ok(tls_stream.into()) +} + +pub fn configure_rustls( + resumption_store: Arc, + dangerous_no_cert_verification: bool, +) -> rustls::ClientConfig { + let root_cert_store = + rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); let mut config = rustls::ClientConfig::builder() .with_root_certificates(root_cert_store) @@ -39,8 +50,5 @@ where .set_certificate_verifier(Arc::new(NoCertificateVerification::default())); } - let tls = tokio_rustls::TlsConnector::from(Arc::new(config)); - let name = rustls::pki_types::ServerName::try_from(hostname)?.to_owned(); - let tls_stream = tls.connect(name, stream).await?; - Ok(tls_stream.into()) + config } diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs index 2e039a25..197fe3f3 100644 --- a/filtermail/src/transport.rs +++ b/filtermail/src/transport.rs @@ -1,45 +1,123 @@ use crate::config::Config; use crate::smtp_client::{SmtpConnectionPool, TlsConfig}; use crate::smtp_server::{Envelope, SmtpHandler}; +use crate::tls; use crate::utils::{AddressDomain, build_resolver}; use async_trait::async_trait; use hickory_resolver::TokioResolver; +use http_body_util::BodyExt; +use hyper::body::Bytes; +use hyper_rustls::HttpsConnector; +use hyper_util::client::legacy::connect::HttpConnector; use std::collections::BTreeMap; use std::str::FromStr; use std::sync::Arc; -use tokio::task::JoinSet; +use std::time::Duration; +use tokio::task::{JoinHandle, JoinSet}; use tokio_rustls::rustls; +pub const HEADER_MAIL_FROM: &str = "X-MAIL-FROM"; +pub const HEADER_RCPT_TO: &str = "X-MAIL-TO"; + +/// Cheaply clonable HTTPS client. +/// +/// Holds regular secure variant and relaxed - without certificate verification. +/// +/// Connection pool handled internally by [`hyper_util::client::legacy::Client`]. +#[derive(Clone)] +struct HttpsClient { + pub secure: hyper_util::client::legacy::Client< + HttpsConnector, + http_body_util::Full, + >, + pub relaxed: hyper_util::client::legacy::Client< + HttpsConnector, + http_body_util::Full, + >, +} + +impl HttpsClient { + /// Creates a new `[HttpsClient]`. + pub fn new(tls_resumption_store: Arc) -> Self { + let tls_client_config = tls::configure_rustls(tls_resumption_store.clone(), false); + let https_connector = hyper_rustls::HttpsConnectorBuilder::new() + .with_tls_config(tls_client_config) + .https_only() + .enable_http1() + .enable_http2() + .build(); + let https_client = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build(https_connector); + + let tls_client_config_relaxed = tls::configure_rustls(tls_resumption_store, true); + let https_connector_relaxed = hyper_rustls::HttpsConnectorBuilder::new() + .with_tls_config(tls_client_config_relaxed) + .https_only() + .enable_http1() + .enable_http2() + .build(); + let https_client_relaxed = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build(https_connector_relaxed); + + Self { + secure: https_client, + relaxed: https_client_relaxed, + } + } +} + pub struct TransportHandler { config: Config, dns_resolver: Arc, tls_resumption_store: Arc, smtp_connection_pool: Arc, + https_client: HttpsClient, + mxdeliv_unsupported_hosts: Arc>, + monitor_handle: JoinHandle<()>, } impl TransportHandler { pub fn new(config: Config) -> Result { let dns_resolver = Arc::new(build_resolver()?); let tls_resumption_store = Arc::new(rustls::client::ClientSessionMemoryCache::new(256)); + let https_client = HttpsClient::new(tls_resumption_store.clone()); + + let mxdeliv_cache = Arc::new(retainer::Cache::new()); + let mxdeliv_cache_clone = mxdeliv_cache.clone(); + + let monitor_handle = tokio::spawn(async move { + mxdeliv_cache_clone + .monitor(4, 0.25, Duration::from_secs(10)) + .await + }); + Ok(Self { config, dns_resolver, tls_resumption_store, smtp_connection_pool: SmtpConnectionPool::new(), + https_client, + mxdeliv_unsupported_hosts: mxdeliv_cache, + monitor_handle, }) } /// Handles a single email transaction for a single recipient domain. + #[expect(clippy::too_many_arguments)] async fn handle_single_domain( tls_resumption_store: Arc, smtp_connection_pool: Arc, + mxdeliv_unsupported_hosts: Arc>, + https_client: HttpsClient, dns_resolver: Arc, domain: AddressDomain, envelope: Envelope, client_hostname: String, ) -> Result { let mut allow_invalid_cert = false; - let mut skip_tls = false; + let mut skip_tls = false; // only respected by smtp channel let mx_hosts = match domain { // no-DNS setup; assume the ip from email address is the destination. @@ -110,6 +188,34 @@ impl TransportHandler { // but the IPv4 and IPv6 connections (after `smtp_client::send` resolves mx hostname) // happens in parallel. 'try_relay: for (_, mx_host) in mx_hosts { + let skip_mxdeliv = mxdeliv_unsupported_hosts + .get(&mx_host) + .await + .map(|guard| *guard.value()) + .unwrap_or(false); + + // HTTPS channel + if skip_mxdeliv { + log::debug!("Skipping HTTP delivery to host that failed recently: {mx_host}"); + } else { + match Self::https_delivery( + https_client.clone(), + mx_host.clone(), + &envelope, + allow_invalid_cert, + ) + .await + { + Ok(_) => { + return Ok("250 Ok (HTTPS)".to_string()); + } + Err(e) => { + log::debug!("HTTPS delivery to {mx_host} failed: {e}"); + } + } + } + + // SMTP channel (fallback) match crate::smtp_client::send( &mx_host, 25, @@ -122,7 +228,14 @@ impl TransportHandler { .await { Ok(_) => { - return Ok("250 Ok".to_string()); + // Switches this host to SMTP for 30 minutes. + // Note: this MUST happen only after a successful SMTP delivery, + // or otherwise any http error will lock us out of any way to + // deliver to a relay with a blocked port 25 for 30 minutes. + mxdeliv_unsupported_hosts + .insert(mx_host.clone(), true, Duration::from_mins(30)) + .await; + return Ok("250 Ok (SMTP)".to_string()); } Err(error) => { match error { @@ -152,6 +265,60 @@ impl TransportHandler { Err("421 Failed to connect to any mail server".to_string()) } + + /// Performs mail delivery to `mx_host` over HTTPS. + /// + /// Times out after 60s. + async fn https_delivery( + https_client: HttpsClient, + mx_host: String, + envelope: &Envelope, + allow_invalid_cert: bool, + ) -> Result<(), crate::error::Error> { + let request: hyper::Request> = { + let mut builder = hyper::Request::builder() + .method(hyper::Method::POST) + .uri(format!("https://{mx_host}/mxdeliv")); + + if !envelope.mail_from.is_empty() { + builder = builder.header(HEADER_MAIL_FROM, &envelope.mail_from); + } + + for rcpt_to in &envelope.rcpt_to { + builder = builder.header(HEADER_RCPT_TO, rcpt_to); + } + + builder.body(http_body_util::Full::from(envelope.data.clone()))? + }; + + let client = if allow_invalid_cert { + https_client.relaxed + } else { + https_client.secure + }; + + let response = tokio::time::timeout(Duration::from_secs(60), client.request(request)) + .await + .map_err(|_| crate::error::Error::MailSend { + context: "HTTPS delivery".to_string(), + raw_smtp_answer: "[timeout]".to_string(), + })??; + if response.status().is_success() { + Ok(()) + } else { + let response_body = response.collect().await?.to_bytes(); + Err(crate::error::Error::MailSend { + context: "HTTPS delivery".to_string(), + raw_smtp_answer: String::from_utf8_lossy(&response_body).into(), + }) + } + } +} + +impl Drop for TransportHandler { + fn drop(&mut self) { + self.monitor_handle.abort(); + } } #[async_trait] @@ -201,6 +368,8 @@ impl SmtpHandler for TransportHandler { .spawn(Self::handle_single_domain( self.tls_resumption_store.clone(), self.smtp_connection_pool.clone(), + self.mxdeliv_unsupported_hosts.clone(), + self.https_client.clone(), self.dns_resolver.clone(), rcpt_domain.clone(), domain_envelope, From 5b79d2d08a7e8ec496318bf829d0835ff3bd110d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Fri, 1 May 2026 10:39:46 +0200 Subject: [PATCH 106/166] chore(release): prepare for 0.6.4 (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 13 +++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index 1e5d38ef..afafcafd 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,16 @@ +## 0.6.4 - 2026-05-01 + +### Bug Fixes + +- Implement missing "implicit MX" rule + +### Features + +- Https transport channel (#122) + +### Refactor + +- *(transport)* Explicitly handle RFC7505 null MX ## 0.6.3 - 2026-04-20 ### Features diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 122f887d..b3649b64 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -462,7 +462,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.6.3" +version = "0.6.4" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 69ae0b94..54f497cf 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.6.3" +version = "0.6.4" edition = "2024" license = "MIT" readme = "README.md" From 36ff21ff91eda46daea49918a47db233f957739f Mon Sep 17 00:00:00 2001 From: holger krekel Date: Fri, 1 May 2026 17:58:11 +0200 Subject: [PATCH 107/166] ci: build and deploy relays with filtermail binary and run interop tests against madmail (#131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: Run relay integration tests using cmlxc Builds filtermail and runs cmlxc tests against a patched chatmail/relay. Signed-off-by: Jagoda Ślązak * ci: run integration tests with branch filtermail binary deployed with chatmail/relay main code --------- Signed-off-by: Jagoda Ślązak Co-authored-by: Jagoda Ślązak --- filtermail/.github/workflows/ci.yml | 32 +++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index c8a10933..c42d6810 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -11,6 +11,10 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: fmt: name: Check Formatting @@ -59,8 +63,8 @@ jobs: env: RUSTUP_TOOLCHAIN: ${{ matrix.toolchain }} - test: - name: Test on ${{ matrix.os }} + rust-test: + name: Run rust tests on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -75,3 +79,27 @@ jobs: toolchain: stable - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - run: cargo test + + relay-test: + name: Test the relay with cmlxc and custom filtermail + uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@main + with: + cmlxc_commands: | + sudo apt-get update && sudo apt-get install -y musl-tools + rustup target add x86_64-unknown-linux-musl + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc + cd repo && RUSTFLAGS="-Ctarget-feature=+crt-static -Clink-self-contained=yes" cargo build --release --target x86_64-unknown-linux-musl + cmlxc init + cmlxc -v deploy-cmdeploy --filtermail repo/target/x86_64-unknown-linux-musl/release/filtermail cm0 + cmlxc -v test-mini cm0 + cmlxc -v test-cmdeploy cm0 + + # cross cmdeploy relay test + cmlxc -v deploy-cmdeploy --filtermail repo/target/x86_64-unknown-linux-musl/release/filtermail --ipv4-only cm1 + cmlxc -v test-cmdeploy cm0 cm1 + + # cross cmdeploy/madmail relay tests + cmlxc -v deploy-madmail mad0 + cmlxc -v test-cmdeploy cm0 mad0 + cmlxc -v test-mini cm0 mad0 + cmlxc -v test-mini mad0 cm0 From 94dcd6c728307573ad0de7dafc2131ee523cbe5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 09:44:33 +0200 Subject: [PATCH 108/166] chore(deps): Bump taiki-e/install-action from 2.75.17 to 2.75.22 (#132) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.17 to 2.75.22. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/58e862542551f667fa44c8a2a4a1d64ad477c96a...cf525cb33f51aca27cd6fa02034117ab963ff9f1) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.75.22 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index c42d6810..618cba4b 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - - uses: taiki-e/install-action@58e862542551f667fa44c8a2a4a1d64ad477c96a # v2 + - uses: taiki-e/install-action@cf525cb33f51aca27cd6fa02034117ab963ff9f1 # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index 842913bc..244e27af 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@58e862542551f667fa44c8a2a4a1d64ad477c96a # v2 + - uses: taiki-e/install-action@cf525cb33f51aca27cd6fa02034117ab963ff9f1 # v2 with: tool: cargo-zigbuild - name: Build From 457461b4ff0b968b0c45527c4ec02b5fc58f1377 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 09:45:26 +0200 Subject: [PATCH 109/166] chore(deps): Bump lru from 0.16.3 to 0.17.0 (#133) Bumps [lru](https://github.com/jeromefroe/lru-rs) from 0.16.3 to 0.17.0. - [Changelog](https://github.com/jeromefroe/lru-rs/blob/master/CHANGELOG.md) - [Commits](https://github.com/jeromefroe/lru-rs/compare/0.16.3...0.17.0) --- updated-dependencies: - dependency-name: lru dependency-version: 0.17.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/Cargo.lock | 17 ++++++++++++++--- filtermail/Cargo.toml | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index b3649b64..9b47a4d1 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -695,6 +695,17 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "heck" version = "0.5.0" @@ -1095,11 +1106,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.16.3" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "0e0b564323a0fb6d54b864f625ae139de9612e27edb944dda37c109f05aac531" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.0", ] [[package]] diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 54f497cf..5bcd9de1 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -21,7 +21,7 @@ env_logger = "0.11.10" governor = "0.10.4" viadkim = { version = "0.2.0" } hickory-resolver = { version = "0.25.2", features = ["dnssec-ring"] } -lru = "0.16.3" +lru = "0.17.0" parking_lot = "0.12.5" tokio-rustls = { version = "0.26.4", default-features = false, features = [ "ring", From 59944a46fffb44dddbd95169b728a3fb42eb7479 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 12:52:55 +0200 Subject: [PATCH 110/166] chore(deps): Bump tokio from 1.51.1 to 1.52.1 (#135) Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.51.1 to 1.52.1. - [Release notes](https://github.com/tokio-rs/tokio/releases) - [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.51.1...tokio-1.52.1) --- updated-dependencies: - dependency-name: tokio dependency-version: 1.52.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/Cargo.lock | 4 ++-- filtermail/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 9b47a4d1..536364a2 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -1930,9 +1930,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.1" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 5bcd9de1..4caaabc1 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -13,7 +13,7 @@ async-trait = "0.1.89" base64 = "0.22.1" serde = { version = "1.0.228", features = ["derive"] } serini = "0.2.2" -tokio = { version = "1.51.0", features = ["full"] } +tokio = { version = "1.52.1", features = ["full"] } thiserror = "2.0.18" mailparse = "0.16.1" log = "0.4.29" From bc769e0d2a29b02a15ccff6a00874679ffa677f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 12:54:28 +0200 Subject: [PATCH 111/166] chore(deps): Bump webpki-roots from 1.0.6 to 1.0.7 (#136) Bumps [webpki-roots](https://github.com/rustls/webpki-roots) from 1.0.6 to 1.0.7. - [Release notes](https://github.com/rustls/webpki-roots/releases) - [Commits](https://github.com/rustls/webpki-roots/compare/v/1.0.6...v/1.0.7) --- updated-dependencies: - dependency-name: webpki-roots dependency-version: 1.0.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/Cargo.lock | 4 ++-- filtermail/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 536364a2..821a966f 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -2244,9 +2244,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 4caaabc1..9dc72f44 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -28,7 +28,7 @@ tokio-rustls = { version = "0.26.4", default-features = false, features = [ "logging", "tls12", ] } -webpki-roots = "1.0.6" +webpki-roots = "1.0.7" tokio-io-timeout = "1.2.1" retainer = "0.4.0" hyper = { version = "1.8.1", features = ["server"] } From d29cbafda4829410961c36e8d3c21f90f5379087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 5 May 2026 02:17:21 +0200 Subject: [PATCH 112/166] chore(deps): Upgrade hickory-resolver (#138) --- filtermail/Cargo.lock | 505 ++++++++++++++++++++++++++++++-- filtermail/Cargo.toml | 2 +- filtermail/src/dkim_verifier.rs | 18 +- filtermail/src/error.rs | 2 +- filtermail/src/transport.rs | 15 +- filtermail/src/utils.rs | 12 +- 6 files changed, 506 insertions(+), 48 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 821a966f..bb682515 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -67,6 +67,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "async-io" version = "2.6.0" @@ -174,6 +180,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "charset" version = "0.1.5" @@ -190,6 +207,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -205,6 +232,22 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -214,6 +257,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -261,7 +313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -367,6 +419,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -376,18 +434,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "env_filter" version = "1.0.0" @@ -502,6 +548,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -625,11 +677,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + [[package]] name = "glob" version = "0.3.3" @@ -684,6 +750,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -692,7 +767,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -703,7 +778,7 @@ checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -719,23 +794,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] -name = "hickory-proto" -version = "0.25.2" +name = "hickory-net" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" dependencies = [ "async-trait", "bitflags", "cfg-if", "data-encoding", - "enum-as-inner", "futures-channel", "futures-io", "futures-util", + "hickory-proto", "idna 1.1.0", "ipnet", - "once_cell", - "rand 0.9.2", + "jni", + "lru-cache", + "parking_lot", + "rand 0.10.1", "ring", "rustls-pki-types", "thiserror", @@ -747,21 +824,49 @@ dependencies = [ ] [[package]] -name = "hickory-resolver" -version = "0.25.2" +name = "hickory-proto" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "bitflags", + "data-encoding", + "idna 1.1.0", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "rustls-pki-types", + "thiserror", + "time", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" dependencies = [ "cfg-if", "futures-util", + "hickory-net", "hickory-proto", "ipconfig", + "ipnet", + "jni", "moka", + "ndk-context", "once_cell", "parking_lot", - "rand 0.9.2", + "rand 0.10.1", "resolv-conf", "smallvec", + "system-configuration", "thiserror", "tokio", "tracing", @@ -951,6 +1056,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "0.4.0" @@ -990,6 +1101,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -1009,6 +1122,9 @@ name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +dependencies = [ + "serde", +] [[package]] name = "is_terminal_polyfill" @@ -1046,6 +1162,55 @@ dependencies = [ "syn", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -1065,6 +1230,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.184" @@ -1077,6 +1248,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1113,6 +1290,15 @@ dependencies = [ "hashbrown 0.17.0", ] +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "mailparse" version = "0.16.1" @@ -1158,6 +1344,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "nonzero_ext" version = "0.3.0" @@ -1362,6 +1554,27 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prefix-trie" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23370be78b7e5bcbb0cab4a02047eb040279a693c78daad04c2c5f1c24a83503" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -1416,6 +1629,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -1436,6 +1655,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -1474,6 +1704,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -1672,6 +1908,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1714,6 +1959,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serini" version = "0.2.2" @@ -1731,7 +1989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1761,6 +2019,22 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.11" @@ -1852,6 +2126,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -2089,6 +2384,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -2153,6 +2454,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2177,6 +2488,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -2222,6 +2542,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -2273,6 +2627,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2457,6 +2820,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -2566,3 +3011,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 9dc72f44..a07f00c2 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -20,7 +20,7 @@ log = "0.4.29" env_logger = "0.11.10" governor = "0.10.4" viadkim = { version = "0.2.0" } -hickory-resolver = { version = "0.25.2", features = ["dnssec-ring"] } +hickory-resolver = { version = "0.26.1", features = ["dnssec-ring"] } lru = "0.17.0" parking_lot = "0.12.5" tokio-rustls = { version = "0.26.4", default-features = false, features = [ diff --git a/filtermail/src/dkim_verifier.rs b/filtermail/src/dkim_verifier.rs index b12e0cb3..7fc62938 100644 --- a/filtermail/src/dkim_verifier.rs +++ b/filtermail/src/dkim_verifier.rs @@ -1,4 +1,4 @@ -use hickory_resolver::{Name, TokioResolver}; +use hickory_resolver::{TokioResolver, proto::rr::Name}; use lru::LruCache; use std::io; use std::num::NonZeroUsize; @@ -84,22 +84,26 @@ impl LookupTxt for CachedResolver { log::debug!("Trying to resolve TXT records for {}", name); let txts: Vec> = { - let lookup = self.dns_resolver.txt_lookup(name.clone()).await?; + let lookup = self + .dns_resolver + .txt_lookup(name.clone()) + .await + .map_err(io::Error::other)?; // viadkim would filter out non-DKIM TXT records, // but we filter it here anyway so that we know which one should be cached. lookup - .into_iter() + .answers() + .iter() // We don't check all records, as this can be a DoS attack vector. // In theory, selector domains should only have a single TXT record. // In practice, we check at most 3, just in case of weird configuration. .take(3) .map(|txt| { - let rdata_raw = txt.txt_data().concat(); - let rdata = String::from_utf8_lossy(&rdata_raw); - log::trace!("TXT (concat): {:?}", rdata); + let rdata = txt.data.to_string(); + log::trace!("TXT (raw rdata): {:?}", rdata); let normalized = normalize_rdata(&rdata); - log::trace!("TXT (normalized): {:?}", normalized); + log::trace!("TXT (concatenated and normalized): {:?}", normalized); normalized.into_bytes() }) .collect() diff --git a/filtermail/src/error.rs b/filtermail/src/error.rs index d329bbdd..546881ea 100644 --- a/filtermail/src/error.rs +++ b/filtermail/src/error.rs @@ -11,7 +11,7 @@ pub enum Error { #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] - Resolve(#[from] hickory_resolver::ResolveError), + Resolve(#[from] hickory_resolver::net::NetError), #[error("OpenPGP packet header is truncated - can't validate!")] TruncatedHeader, #[error("Unable to send email, Error during {context}, server said: {raw_smtp_answer}")] diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs index 197fe3f3..a6f0fc40 100644 --- a/filtermail/src/transport.rs +++ b/filtermail/src/transport.rs @@ -4,7 +4,7 @@ use crate::smtp_server::{Envelope, SmtpHandler}; use crate::tls; use crate::utils::{AddressDomain, build_resolver}; use async_trait::async_trait; -use hickory_resolver::TokioResolver; +use hickory_resolver::{TokioResolver, proto::rr::RData}; use http_body_util::BodyExt; use hyper::body::Bytes; use hyper_rustls::HttpsConnector; @@ -141,9 +141,14 @@ impl TransportHandler { match dns_resolver.mx_lookup(query).await { Ok(mx_records) => { let mut hosts: Vec<(u16, String)> = Vec::new(); - for mx in mx_records { + for mx_record in mx_records.answers() { + let mx = match mx_record.data { + RData::MX(ref mx) => mx, + _ => continue, + }; + // Null MX / RFC7505 - if mx.exchange().is_root() { + if mx.exchange.is_root() { // From RFC7505 section 3: // > A domain that advertises a null MX MUST NOT // > advertise any other MX RR. @@ -154,8 +159,8 @@ impl TransportHandler { ); } - let host = mx.exchange().to_string().trim_end_matches('.').to_string(); - hosts.push((mx.preference(), host)) + let host = mx.exchange.to_string().trim_end_matches('.').to_string(); + hosts.push((mx.preference, host)) } hosts.sort(); hosts diff --git a/filtermail/src/utils.rs b/filtermail/src/utils.rs index bf32acb7..78d38ff5 100644 --- a/filtermail/src/utils.rs +++ b/filtermail/src/utils.rs @@ -1,8 +1,8 @@ -use hickory_resolver::TokioResolver; -use hickory_resolver::name_server::TokioConnectionProvider; +use hickory_resolver::{TokioResolver, proto::dnssec::TrustAnchors}; use mailparse::MailAddr; use std::path::PathBuf; use std::str::FromStr; +use std::sync::Arc; /// Extracts the first email address found in SMTP command or email header. /// @@ -106,12 +106,10 @@ pub async fn log_eml(reason: &str, data: &[u8]) -> Result Result { - let dns_resolver = { - let mut builder = TokioResolver::builder(TokioConnectionProvider::default())?; + let dns_resolver = TokioResolver::builder_tokio()? // https://github.com/hickory-dns/hickory-dns/issues/3519 - builder.options_mut().validate = true; - builder.build() - }; + .with_trust_anchor(Arc::new(TrustAnchors::default())) + .build()?; assert!( dns_resolver.options().validate, From 10119033a33d5865e471744b6c3a1a6b1f636e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Thu, 7 May 2026 14:20:49 +0200 Subject: [PATCH 113/166] fix(smtp-client): Handle 421 on reused connection (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a reused connection was closed in the meantime, open a new connection, instead of failing immediately. Fixes #143 Signed-off-by: Jagoda Ślązak --- filtermail/src/smtp_client.rs | 36 +++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/filtermail/src/smtp_client.rs b/filtermail/src/smtp_client.rs index b35c1b4b..be012554 100644 --- a/filtermail/src/smtp_client.rs +++ b/filtermail/src/smtp_client.rs @@ -216,7 +216,7 @@ pub async fn send( } (stream, true) } else { - let stream = establish_tcp_connection(address, port, dns_resolver).await?; + let stream = establish_tcp_connection(address, port, dns_resolver.clone()).await?; log::debug!("Successfully connected to {}", stream.peer_addr()?); (BufStream::new(SmtpStream::plain(stream)), false) }; @@ -232,7 +232,7 @@ pub async fn send( } macro_rules! smtp_read { - ($context:expr, $expected_code:expr) => { + ($context:expr) => { response.clear(); let mut next_line = String::new(); buf_stream.read_line(&mut next_line).await?; @@ -245,6 +245,15 @@ pub async fn send( response.push_str(&next_line); } log::trace!("SMTP response for {}:\n{}", $context, response); + }; + ($context:expr, $expected_code:expr) => { + smtp_read!($context); + smtp_expect!($context, $expected_code); + }; + } + + macro_rules! smtp_expect { + ($context:expr, $expected_code:expr) => { if !response.starts_with($expected_code) { return Err(crate::error::Error::MailSend { context: $context.to_string(), @@ -261,9 +270,28 @@ pub async fn send( }; } - if reused { - smtp_cmd!(b"RSET\r\n", "RSET on reused connection", "250"); + // RSET reused connection or fallback to a new connection + let reused = if reused { + smtp_write!(b"RSET\r\n"); + smtp_read!("RSET"); + // We don't want to defer if the connection was closed already by the server. + // This is a special case where we end up reading message sent before we sent RSET. + // e.g.: 421 example.org Service closing transmission channel - command timeout + if response.starts_with("421") { + log::debug!("Reused connection is dead; establishing new connection..."); + let stream = establish_tcp_connection(address, port, dns_resolver).await?; + log::debug!("Successfully connected to {}", stream.peer_addr()?); + buf_stream = BufStream::new(SmtpStream::plain(stream)); + false + } else { + smtp_expect!("RSET", "250"); + true + } } else { + false + }; + + if !reused { // Read initial greeting smtp_read!("initial greeting", "220"); From 5295d175d61e79e7b0a0d4207fa58062633ad759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Thu, 7 May 2026 15:05:51 +0200 Subject: [PATCH 114/166] perf(smtp-client): Use pipelining if server advertises support (#146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/src/smtp_client.rs | 174 ++++++++++++++++++++++------------ 1 file changed, 113 insertions(+), 61 deletions(-) diff --git a/filtermail/src/smtp_client.rs b/filtermail/src/smtp_client.rs index be012554..852ddeda 100644 --- a/filtermail/src/smtp_client.rs +++ b/filtermail/src/smtp_client.rs @@ -11,13 +11,19 @@ use tokio::task::{JoinHandle, JoinSet}; use tokio_io_timeout::TimeoutStream; use tokio_rustls::rustls::client::ClientSessionMemoryCache; +/// Wraps SMTP connection, contains stream and ESMTP support information. +pub struct SmtpConnection { + pub stream: BufStream, + pub pipelining: bool, +} + /// A connection pool for SMTP connections, keyed by (address, port). /// /// Connections are cached for up to 100 seconds of idle time. /// /// Only a single connection is cached per address/port pair. pub struct SmtpConnectionPool { - pool: Arc>>, + pool: Arc>, monitor_handle: JoinHandle<()>, } @@ -37,17 +43,17 @@ impl SmtpConnectionPool { } /// Takes a connection from the pool for the given address and port, if available. - pub async fn take(&self, address: &str, port: u16) -> Option> { + pub async fn take(&self, address: &str, port: u16) -> Option { self.pool.remove(&(address.to_string(), port)).await } /// Puts a connection into the pool for the given address and port, with a 100s timeout. - pub async fn put(&self, address: &str, port: u16, stream: BufStream) { + pub async fn put(&self, address: &str, port: u16, connection: SmtpConnection) { // similarly to postfix default -> 100s max idle time. self.pool .insert( (address.to_string(), port), - stream, + connection, Duration::from_secs(100), ) .await; @@ -204,22 +210,23 @@ pub async fn send( dns_resolver: Arc, pool: Arc, ) -> Result<(), crate::error::Error> { - let (mut buf_stream, reused) = if let Some(stream) = pool.take(address, port).await { - log::debug!("Reusing existing connection to {address}:{port}",); - if tls_config.is_some() { - // This should never happen, - // assert to make sure we never accidentally use a plain connection while expecting TLS. - assert!( - matches!(stream.get_ref(), SmtpStream::Tls(_)), - "Expected TLS stream from pool, but got plain stream." - ); - } - (stream, true) - } else { - let stream = establish_tcp_connection(address, port, dns_resolver.clone()).await?; - log::debug!("Successfully connected to {}", stream.peer_addr()?); - (BufStream::new(SmtpStream::plain(stream)), false) - }; + let (mut buf_stream, reused, mut pipelining) = + if let Some(connection) = pool.take(address, port).await { + log::debug!("Reusing existing connection to {address}:{port}",); + if tls_config.is_some() { + // This should never happen, + // assert to make sure we never accidentally use a plain connection while expecting TLS. + assert!( + matches!(connection.stream.get_ref(), SmtpStream::Tls(_)), + "Expected TLS stream from pool, but got plain stream." + ); + } + (connection.stream, true, connection.pipelining) + } else { + let stream = establish_tcp_connection(address, port, dns_resolver.clone()).await?; + log::debug!("Successfully connected to {}", stream.peer_addr()?); + (BufStream::new(SmtpStream::plain(stream)), false, false) + }; let mut response = String::new(); @@ -246,28 +253,30 @@ pub async fn send( } log::trace!("SMTP response for {}:\n{}", $context, response); }; - ($context:expr, $expected_code:expr) => { + ($context:expr, $expected_code:expr) => {{ smtp_read!($context); - smtp_expect!($context, $expected_code); - }; + smtp_expect!($context, $expected_code) + }}; } macro_rules! smtp_expect { ($context:expr, $expected_code:expr) => { if !response.starts_with($expected_code) { - return Err(crate::error::Error::MailSend { + Err(crate::error::Error::MailSend { context: $context.to_string(), raw_smtp_answer: response.clone(), - }); + }) + } else { + Ok(()) } }; } macro_rules! smtp_cmd { - ($command:expr, $context:expr, $expected_code:expr) => { + ($command:expr, $context:expr, $expected_code:expr) => {{ smtp_write!($command); - smtp_read!($context, $expected_code); - }; + smtp_read!($context, $expected_code) + }}; } // RSET reused connection or fallback to a new connection @@ -284,7 +293,7 @@ pub async fn send( buf_stream = BufStream::new(SmtpStream::plain(stream)); false } else { - smtp_expect!("RSET", "250"); + smtp_expect!("RSET", "250")?; true } } else { @@ -293,23 +302,21 @@ pub async fn send( if !reused { // Read initial greeting - smtp_read!("initial greeting", "220"); + smtp_read!("initial greeting", "220")?; - if tls_config.is_some() { - smtp_cmd!( - format!("EHLO {client_hostname}\r\n").as_bytes(), - "EHLO", - "250" - ); - } else { - smtp_cmd!( - format!("HELO {client_hostname}\r\n").as_bytes(), - "HELO", - "250" - ); - }; + smtp_cmd!( + format!("EHLO {client_hostname}\r\n").as_bytes(), + "EHLO", + "250" + )?; - // STARTTLS + // ESMTP: PIPELINING + if response.to_uppercase().contains("PIPELINING") { + pipelining = true; + log::debug!("Using pipelining"); + } + + // ESMTP: STARTTLS if let Some(tls_config) = tls_config { if !response.to_uppercase().contains("STARTTLS") { // TLS was requested, but server doesn't support STARTTLS. @@ -320,7 +327,7 @@ pub async fn send( } log::trace!("Initiating STARTTLS..."); - smtp_cmd!(b"STARTTLS\r\n", "STARTTLS", "220"); + smtp_cmd!(b"STARTTLS\r\n", "STARTTLS", "220")?; let stream = buf_stream.into_inner(); let raw_tcp = match stream { @@ -346,33 +353,78 @@ pub async fn send( format!("EHLO {client_hostname}\r\n").as_bytes(), "EHLO after STARTTLS", "250" - ); + )?; } } // MAIL FROM - smtp_cmd!( - format!("MAIL FROM:<{}>\r\n", envelope.mail_from).as_bytes(), - "MAIL FROM", - "250" - ); + smtp_write!(format!("MAIL FROM:<{}>\r\n", envelope.mail_from).as_bytes()); + if !pipelining { + smtp_read!("MAIL FROM", "250")?; + } // RCPT TO for rcpt in &envelope.rcpt_to { - smtp_cmd!( - format!("RCPT TO:<{}>\r\n", rcpt).as_bytes(), - "RCPT TO", - "250" - ); + smtp_write!(format!("RCPT TO:<{}>\r\n", rcpt).as_bytes()); + if !pipelining { + smtp_read!("RCPT TO", "250")?; + } } // DATA - smtp_cmd!(b"DATA\r\n", "DATA", "354"); - smtp_write!(&envelope.data); - smtp_write!(b".\r\n"); - smtp_read!("end of DATA", "250"); + smtp_write!(b"DATA\r\n"); + if !pipelining { + smtp_read!("DATA", "354")?; + } else { + // We only return first error + let mut error = smtp_read!("MAIL FROM", "250").err(); - pool.put(address, port, buf_stream).await; + for _ in &envelope.rcpt_to { + let result = smtp_read!("RCPT TO", "250"); + if error.is_none() { + error = result.err() + } + } + + let data_354 = if let Err(e) = smtp_read!("DATA", "354") { + if error.is_none() { + error = Some(e); + } + false + } else { + true + }; + + if let Some(e) = error { + // RFC2920 3.1: + // > If the DATA command was properly rejected the client SMTP can just issue RSET, + // > but if the DATA command was accepted the client SMTP should send a single dot. + if data_354 { + log::warn!( + "Server {address} advertised PIPELINING support, \ + but accepted DATA despite error response to at least one \ + previous command in the group: \n\ + {e} \n\ + Sending a single dot (RFC2920 section 3.1)." + ); + smtp_cmd!(b".\r\n", "end of DATA", "250")?; + } + return Err(e); + } + } + + smtp_write!(&envelope.data); + smtp_cmd!(b".\r\n", "end of DATA", "250")?; + + pool.put( + address, + port, + SmtpConnection { + stream: buf_stream, + pipelining, + }, + ) + .await; Ok(()) } From 36921dd29f3cd16ed726153b24ac939deef0bfd9 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Tue, 12 May 2026 06:56:11 +0200 Subject: [PATCH 115/166] fix: advertise 8BITMIME to prevent conversion after DKIM signing (#149) for bounces postfix sets Content-Transfer-Encoding: 8bit which causes a quoted-printable conversion when filtermail SMTP does not offer 8BITMIME, which in turn breaks DKIM verification. --- filtermail/src/smtp_server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index 1a7f8178..62aef491 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -121,7 +121,7 @@ where || cmd.to_uppercase().starts_with("LHLO") { writer - .write_all(b"250-filtermail\r\n250-XFORWARD ADDR\r\n250 OK\r\n") + .write_all(b"250-filtermail\r\n250-XFORWARD ADDR\r\n250-8BITMIME\r\n250 OK\r\n") .await?; writer.flush().await?; } else if cmd.to_uppercase().starts_with("MAIL FROM:<>") { From a65e7a14902ea6a7579b1bafb8b665f533793f6e Mon Sep 17 00:00:00 2001 From: l Date: Tue, 12 May 2026 05:00:21 +0000 Subject: [PATCH 116/166] fix: Validate mail data (#150) Do not allow bare CR and LF etc. --- filtermail/Cargo.lock | 5 +- filtermail/Cargo.toml | 1 + filtermail/src/smtp_server.rs | 88 +++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index bb682515..0356122e 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -522,6 +522,7 @@ dependencies = [ "log", "lru", "mailparse", + "memchr", "parking_lot", "retainer", "rstest", @@ -1312,9 +1313,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mio" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index a07f00c2..a5b1b74c 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -16,6 +16,7 @@ serini = "0.2.2" tokio = { version = "1.52.1", features = ["full"] } thiserror = "2.0.18" mailparse = "0.16.1" +memchr = "2.8.0" log = "0.4.29" env_logger = "0.11.10" governor = "0.10.4" diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index 62aef491..818d9eb3 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -2,6 +2,7 @@ use crate::utils::{extract_address, log_eml}; use async_trait::async_trait; +use memchr::{Memchr, memmem}; use std::sync::Arc; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; use tokio::net::{TcpListener, TcpStream}; @@ -12,9 +13,48 @@ pub struct Envelope { pub mail_from: String, pub origin_ip: String, pub rcpt_to: Vec, + + /// Mail data as transmitted over SMTP/LMTP. + /// + /// Described in . + /// + /// It MUST end with ``, contain no bare `` or `` + /// and have all `.` sequences escaped with `.` according to + /// . pub data: Vec, } +/// Checks if mail data is valid. +fn is_valid_data(data: &[u8]) -> bool { + // DATA must end with . + // + // Otherwise it is not possible to reinject it as is into SMTP/LMTP + // without adding at the end and modifying the message. + if !data.ends_with(b"\r\n") { + return false; + } + + // Check for bare `` and ``. + // + for pos in Memchr::new(b'\r', data) { + if data.get(pos + 1) != Some(&b'\n') { + return false; + } + } + for pos in Memchr::new(b'\n', data) { + if pos == 0 || data.get(pos - 1) != Some(&b'\r') { + return false; + } + } + + // Do not allow unescaped `.`. + if data.starts_with(b".\r\n") || memmem::find(data, b"\r\n.\r\n").is_some() { + return false; + } + + true +} + /// Trait defining the SMTP handler interface. #[async_trait] pub trait SmtpHandler: Send + Sync { @@ -32,6 +72,17 @@ pub trait SmtpHandler: Send + Sync { /// Handles the DATA command. async fn handle_data(&self, envelope: &mut Envelope) -> Result { log::debug!("handle_DATA before-queue"); + + // Check if the DATA is valid + // before doing any custom checks. + // + // We are not going to normalize newlines + // and escape the dots in the mail data. + // If mail data turned out to be invalid, reject immediately. + if !is_valid_data(&envelope.data) { + return Err("500 Invalid DATA".to_string()); + } + self.check_data(envelope).await?; if envelope.rcpt_to.is_empty() { log::warn!("Dropping mail; All recipients disabled."); @@ -246,3 +297,40 @@ where Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use rstest::*; + + #[rstest] + #[case(b"", false)] + #[case(b".", false)] + #[case(b"Hello!\n", false)] + #[case(b"Hello!\n\r", false)] + #[case(b"Hello\nworld!\r\n", false)] + #[case(b"Hello!\r\n\n", false)] + #[case(b"Hello\r\n.\r\n", false)] + #[case(b"Hello!\r\n .\r\n", true)] + #[case(b"Hello!\r\n..\r\n", true)] + #[case(b"Hello!\r\n", true)] + #[case(b"Hello\r\n.world\r\n", true)] + #[case(b"Hello!\r\r\n", false)] + #[case(b"Hello!\r\r\n\n", false)] + #[case(b"Hello\rworld!\r\n", false)] + #[case(b"\n", false)] + #[case(b"\nHello\r\n", false)] + #[case(b"\r", false)] + #[case(b".\r\n", false)] + #[case(b".\r\nHello\r\n", false)] + #[case(b"..\r\n.\r\n", false)] + #[case(b".\r\n..\r\n", false)] + #[case(b"\r\n.\r\n", false)] + #[case(b"..\r\n..\r\n", true)] + #[case(b" .\r\n", true)] + #[case(b"..\r\n", true)] + #[case(b"\r\n", true)] + fn test_is_valid_data(#[case] data: &[u8], #[case] expected: bool) { + assert_eq!(is_valid_data(data), expected, "{data:?}"); + } +} From 272744115f3389e1fd2a614cf2d94a53600ec151 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 07:01:30 +0200 Subject: [PATCH 117/166] chore(deps): Bump taiki-e/install-action from 2.75.22 to 2.75.28 (#148) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.22 to 2.75.28. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/cf525cb33f51aca27cd6fa02034117ab963ff9f1...51cd0b8c0499559d9a4d75c0f5c67bec3a894ec8) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.75.28 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 618cba4b..a1e6ee20 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - - uses: taiki-e/install-action@cf525cb33f51aca27cd6fa02034117ab963ff9f1 # v2 + - uses: taiki-e/install-action@51cd0b8c0499559d9a4d75c0f5c67bec3a894ec8 # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index 244e27af..061bbdf5 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@cf525cb33f51aca27cd6fa02034117ab963ff9f1 # v2 + - uses: taiki-e/install-action@51cd0b8c0499559d9a4d75c0f5c67bec3a894ec8 # v2 with: tool: cargo-zigbuild - name: Build From e26cca0260456e066b254bcb71bacbdb112722dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 12 May 2026 09:52:13 +0200 Subject: [PATCH 118/166] chore(release): prepare for 0.6.5 (#152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 15 +++++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index afafcafd..927801a9 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,18 @@ +## 0.6.5 - 2026-05-12 + +### Bug Fixes + +- *(smtp-client)* Handle 421 on reused connection (#145) +- Advertise 8BITMIME to prevent conversion after DKIM signing (#149) +- Validate mail data (#150) + +### Miscellaneous Tasks + +- Build and deploy relays with filtermail binary and run interop tests against madmail (#131) + +### Performance + +- *(smtp-client)* Use pipelining if server advertises support (#146) ## 0.6.4 - 2026-05-01 ### Bug Fixes diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 0356122e..5a2b6d46 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -508,7 +508,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.6.4" +version = "0.6.5" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index a5b1b74c..aeed8746 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.6.4" +version = "0.6.5" edition = "2024" license = "MIT" readme = "README.md" From 3896044f368b1e8bd222bf249ef52ea37d213634 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Tue, 12 May 2026 12:13:00 +0200 Subject: [PATCH 119/166] fix: return HTTP 200 because madmail expects it, and make sure https is immediately retried when SMTP fails (#153) --- filtermail/src/http_server.rs | 2 +- filtermail/src/transport.rs | 18 ++++++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/filtermail/src/http_server.rs b/filtermail/src/http_server.rs index 1e943ddf..513de58b 100644 --- a/filtermail/src/http_server.rs +++ b/filtermail/src/http_server.rs @@ -137,7 +137,7 @@ impl Service> for MxDelivService match handler.handle_data(&mut envelope).await { Ok(response) => Ok(Response::builder() - .status(201) + .status(200) .body(Full::new(Bytes::from(response)).boxed())?), Err(e) => Ok(Response::builder() .status(400) diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs index a6f0fc40..ce3a17f9 100644 --- a/filtermail/src/transport.rs +++ b/filtermail/src/transport.rs @@ -247,16 +247,14 @@ impl TransportHandler { // We only want to try other MX hosts if we encounter a problem // related to connection. // (So we don't spam other servers if the message is actually rejected.) - crate::error::Error::Io(io_err) => { - log::warn!("I/O error relaying to mail server {mx_host}: {io_err}"); - continue 'try_relay; - } - crate::error::Error::ConnectionFailed(_) => { - log::warn!("Failed to connect to mail server {mx_host}: {error}"); - continue 'try_relay; - } - crate::error::Error::Tls(tls_err) => { - log::warn!("TLS error relaying to mail server {mx_host}: {tls_err}"); + crate::error::Error::Io(_) + | crate::error::Error::ConnectionFailed(_) + | crate::error::Error::Tls(_) => { + // Make sure we quickly retry HTTP if SMTP failed to connect + mxdeliv_unsupported_hosts.remove(&mx_host).await; + log::warn!( + "Connection error relaying to mail server {mx_host}: {error}" + ); continue 'try_relay; } _ => { From b7a213ff1bcc9d46e737215dcbd3fb5774ed7dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 12 May 2026 12:20:25 +0200 Subject: [PATCH 120/166] ci: Remove mac and windows from matrix tests (#154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/.github/workflows/ci.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index a1e6ee20..866de2a1 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -64,12 +64,8 @@ jobs: RUSTUP_TOOLCHAIN: ${{ matrix.toolchain }} rust-test: - name: Run rust tests on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + name: Run rust tests + runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: From d74e5b85b3a7522e4ea39d3262b070b86f10ebb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 12 May 2026 12:37:02 +0200 Subject: [PATCH 121/166] feat: Improved SMTP error responses (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: #140 Signed-off-by: Jagoda Ślązak --- filtermail/src/error.rs | 37 +++++++++--- filtermail/src/smtp_client.rs | 103 +++++++++++++++++++++++++++------- filtermail/src/transport.rs | 23 +++++++- 3 files changed, 134 insertions(+), 29 deletions(-) diff --git a/filtermail/src/error.rs b/filtermail/src/error.rs index 546881ea..873f7134 100644 --- a/filtermail/src/error.rs +++ b/filtermail/src/error.rs @@ -14,10 +14,11 @@ pub enum Error { Resolve(#[from] hickory_resolver::net::NetError), #[error("OpenPGP packet header is truncated - can't validate!")] TruncatedHeader, - #[error("Unable to send email, Error during {context}, server said: {raw_smtp_answer}")] + #[error("Unable to send email, Error during {context}, host {host} said: {raw_smtp_answer}")] MailSend { context: String, raw_smtp_answer: String, + host: String, }, #[error("Invalid email address: {0}")] InvalidEmailAddress(String), @@ -38,14 +39,36 @@ pub enum Error { impl Error { /// Formats [`Error`] as an SMTP response. pub fn smtp_response(&self) -> String { + macro_rules! format_smtp { + ($code:expr) => { + format!("{} {}", $code, self.to_string()) + }; + } + match self { - // We transparently pass postfix/milter errors reported on reinjection + // Errors returned by server we connect to are forwarded. + // We add "(forwarded from ...)" to distinguish these from our local errors. Error::MailSend { - raw_smtp_answer, .. - } => raw_smtp_answer.clone(), - Error::TruncatedHeader => self.to_string(), - Error::InvalidEmailAddress(address) => format!("500 Invalid email address: {address}"), - _ => "451 Local error".to_string(), + raw_smtp_answer, + host, + .. + } => format!("{raw_smtp_answer} (forwarded from {host})"), + + // Permanent errors + Error::TruncatedHeader => format_smtp!("554"), + Error::InvalidEmailAddress(_) => format_smtp!("553"), + Error::InvalidDnsName(_) => format_smtp!("501"), + + // Transient errors + Error::ConnectionFailed(_) => format_smtp!("450"), + // We don't want to leak chatmail.ini config and other local error details. + Error::Config(_) => "451 Filtermail misconfigured; contact admin".to_string(), + Error::Io(_) => "451 I/O error".to_string(), + Error::Tls(_) => "451 TLS error".to_string(), + Error::Resolve(_) => "451 Resolver error".to_string(), + Error::Hyper(_) | Error::HyperHttp(_) | Error::HyperClient(_) => { + "451 HTTP error".to_string() + } } } diff --git a/filtermail/src/smtp_client.rs b/filtermail/src/smtp_client.rs index 852ddeda..2227942b 100644 --- a/filtermail/src/smtp_client.rs +++ b/filtermail/src/smtp_client.rs @@ -84,6 +84,41 @@ impl SmtpStream { timeout_stream.set_read_timeout(Some(Duration::from_secs(60))); Self::Plain(Box::pin(timeout_stream)) } + + /// Returns the peer address of the underlying TCP stream. + pub fn peer_addr(&self) -> std::io::Result { + match self { + SmtpStream::Plain(stream) => stream.get_ref().peer_addr(), + SmtpStream::Tls(stream) => stream.get_ref().0.get_ref().peer_addr(), + } + } + + /// Formats a peer host, including underlying TCP connection's socket address. + /// + /// Returns either: + /// - `:` if `address` is an IP matching underlying TCP connection. + /// - `
[:]` otherwise. + /// + /// `` is either `` or `[]`. + /// + /// Infallible, fallbacks to `
[?:?]` if peer address is unavailable. + fn format_host(&self, address: &str) -> String { + let socket_addr = self.peer_addr().ok(); + Self::format_host_inner(address, socket_addr) + } + + /// Internal logic of [`SmtpStream::format_host`], only for testing purposes. + fn format_host_inner(address: &str, socket_addr: Option) -> String { + let socket_addr_str = if let Some(socket_addr) = socket_addr { + if socket_addr.ip().to_string().eq_ignore_ascii_case(address) { + return socket_addr.to_string(); + } + socket_addr.to_string() + } else { + "?:?".to_string() + }; + format!("{address}[{socket_addr_str}]") + } } #[derive(Debug, Clone)] @@ -210,23 +245,28 @@ pub async fn send( dns_resolver: Arc, pool: Arc, ) -> Result<(), crate::error::Error> { - let (mut buf_stream, reused, mut pipelining) = - if let Some(connection) = pool.take(address, port).await { - log::debug!("Reusing existing connection to {address}:{port}",); - if tls_config.is_some() { - // This should never happen, - // assert to make sure we never accidentally use a plain connection while expecting TLS. - assert!( - matches!(connection.stream.get_ref(), SmtpStream::Tls(_)), - "Expected TLS stream from pool, but got plain stream." - ); - } - (connection.stream, true, connection.pipelining) - } else { - let stream = establish_tcp_connection(address, port, dns_resolver.clone()).await?; - log::debug!("Successfully connected to {}", stream.peer_addr()?); - (BufStream::new(SmtpStream::plain(stream)), false, false) - }; + let (mut buf_stream, reused, mut pipelining) = if let Some(connection) = + pool.take(address, port).await + { + log::debug!( + "Reusing existing connection to {}", + connection.stream.get_ref().format_host(address) + ); + if tls_config.is_some() { + // This should never happen, + // assert to make sure we never accidentally use a plain connection while expecting TLS. + assert!( + matches!(connection.stream.get_ref(), SmtpStream::Tls(_)), + "Expected TLS stream from pool, but got plain stream." + ); + } + (connection.stream, true, connection.pipelining) + } else { + let stream = + SmtpStream::plain(establish_tcp_connection(address, port, dns_resolver.clone()).await?); + log::debug!("Successfully connected to {}", stream.format_host(address)); + (BufStream::new(stream), false, false) + }; let mut response = String::new(); @@ -265,6 +305,7 @@ pub async fn send( Err(crate::error::Error::MailSend { context: $context.to_string(), raw_smtp_answer: response.clone(), + host: buf_stream.get_ref().format_host(address), }) } else { Ok(()) @@ -323,6 +364,7 @@ pub async fn send( return Err(crate::error::Error::MailSend { context: "STARTTLS".to_string(), raw_smtp_answer: response.clone(), + host: buf_stream.get_ref().format_host(address), }); } @@ -401,11 +443,12 @@ pub async fn send( // > but if the DATA command was accepted the client SMTP should send a single dot. if data_354 { log::warn!( - "Server {address} advertised PIPELINING support, \ + "Server {} advertised PIPELINING support, \ but accepted DATA despite error response to at least one \ previous command in the group: \n\ {e} \n\ - Sending a single dot (RFC2920 section 3.1)." + Sending a single dot (RFC2920 section 3.1).", + buf_stream.get_ref().format_host(address) ); smtp_cmd!(b".\r\n", "end of DATA", "250")?; } @@ -428,3 +471,25 @@ pub async fn send( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use std::net::SocketAddr; + + #[rstest] + #[case::ipv4("192.0.2.0:25".parse().ok(), "192.0.2.0", "192.0.2.0:25")] + #[case::ipv6("[2001:db8::1]:25".parse().ok(), "2001:db8::1", "[2001:db8::1]:25")] + #[case::domain_ipv4("192.0.2.0:25".parse().ok(), "example.org", "example.org[192.0.2.0:25]")] + #[case::domain_ipv6("[2001:db8::1]:25".parse().ok(), "example.org", "example.org[[2001:db8::1]:25]")] + #[case::unknown(None, "example.org", "example.org[?:?]")] + fn test_format_host_inner( + #[case] socket_addr: Option, + #[case] host: &str, + #[case] expected: &str, + ) { + let result = SmtpStream::format_host_inner(host, socket_addr); + assert_eq!(result, expected); + } +} diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs index ce3a17f9..cda9442e 100644 --- a/filtermail/src/transport.rs +++ b/filtermail/src/transport.rs @@ -189,6 +189,8 @@ impl TransportHandler { }), }; + let mut last_error = None; + // we try sequentially in order of MX preference, // but the IPv4 and IPv6 connections (after `smtp_client::send` resolves mx hostname) // happens in parallel. @@ -243,7 +245,7 @@ impl TransportHandler { return Ok("250 Ok (SMTP)".to_string()); } Err(error) => { - match error { + match &error { // We only want to try other MX hosts if we encounter a problem // related to connection. // (So we don't spam other servers if the message is actually rejected.) @@ -255,18 +257,31 @@ impl TransportHandler { log::warn!( "Connection error relaying to mail server {mx_host}: {error}" ); + last_error = Some((error.smtp_response(), mx_host.clone())); continue 'try_relay; } - _ => { + crate::error::Error::MailSend { .. } => { log::warn!("Message rejected by mail server {mx_host}: {error}"); return Err(error.smtp_response()); } + _ => { + log::warn!( + "Unexpected error while delivering to mail server {mx_host}: {error}" + ); + return Err(format!( + "{} (while attempting delivery to {mx_host})", + error.smtp_response() + )); + } } } } } - Err("421 Failed to connect to any mail server".to_string()) + let (error, mx_host) = last_error.unwrap_or(("?".to_string(), "?".to_string())); + Err(format!( + "421 Failed to connect to any mail server; last attempt to {mx_host}: {error}" + )) } /// Performs mail delivery to `mx_host` over HTTPS. @@ -305,6 +320,7 @@ impl TransportHandler { .map_err(|_| crate::error::Error::MailSend { context: "HTTPS delivery".to_string(), raw_smtp_answer: "[timeout]".to_string(), + host: mx_host.clone(), })??; if response.status().is_success() { Ok(()) @@ -313,6 +329,7 @@ impl TransportHandler { Err(crate::error::Error::MailSend { context: "HTTPS delivery".to_string(), raw_smtp_answer: String::from_utf8_lossy(&response_body).into(), + host: mx_host, }) } } From e2be5dc92d481269f26b942d7c22c529a4201d7e Mon Sep 17 00:00:00 2001 From: holger krekel Date: Tue, 12 May 2026 12:32:07 +0200 Subject: [PATCH 122/166] ci: run cmlxc tests in all classic/classic-ipv4/madmail combinations --- filtermail/.github/workflows/ci.yml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 866de2a1..418a1786 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -87,15 +87,21 @@ jobs: cd repo && RUSTFLAGS="-Ctarget-feature=+crt-static -Clink-self-contained=yes" cargo build --release --target x86_64-unknown-linux-musl cmlxc init cmlxc -v deploy-cmdeploy --filtermail repo/target/x86_64-unknown-linux-musl/release/filtermail cm0 - cmlxc -v test-mini cm0 - cmlxc -v test-cmdeploy cm0 - - # cross cmdeploy relay test - cmlxc -v deploy-cmdeploy --filtermail repo/target/x86_64-unknown-linux-musl/release/filtermail --ipv4-only cm1 - cmlxc -v test-cmdeploy cm0 cm1 - - # cross cmdeploy/madmail relay tests + cmlxc -v deploy-cmdeploy --filtermail repo/target/x86_64-unknown-linux-musl/release/filtermail --type ipv4 cm1 cmlxc -v deploy-madmail mad0 + + # single-relay tests + cmlxc -v test-cmdeploy cm0 + cmlxc -v test-cmdeploy cm1 + cmlxc -v test-mini cm0 + cmlxc -v test-mini cm1 + + # cross-relay tests (Standard <-> IP-only <-> Madmail) + cmlxc -v test-cmdeploy cm0 cm1 + cmlxc -v test-cmdeploy cm1 cm0 cmlxc -v test-cmdeploy cm0 mad0 + cmlxc -v test-cmdeploy cm1 mad0 cmlxc -v test-mini cm0 mad0 cmlxc -v test-mini mad0 cm0 + cmlxc -v test-mini cm1 mad0 + cmlxc -v test-mini mad0 cm1 From 91d09ab370f19528469e7be15b94b444e7df5ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 12 May 2026 13:02:17 +0200 Subject: [PATCH 123/166] chore(release): prepare for 0.6.6 (#156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 14 ++++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index 927801a9..b16550a1 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,17 @@ +## 0.6.6 - 2026-05-12 + +### Bug Fixes + +- Return HTTP 200 because madmail expects it, and make sure https is immediately retried when SMTP fails (#153) + +### Features + +- Improved SMTP error responses (#147) + +### Miscellaneous Tasks + +- Remove mac and windows from matrix tests (#154) +- Run cmlxc tests in all classic/classic-ipv4/madmail combinations ## 0.6.5 - 2026-05-12 ### Bug Fixes diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 5a2b6d46..904ad901 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -508,7 +508,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.6.5" +version = "0.6.6" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index aeed8746..080ad35f 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.6.5" +version = "0.6.6" edition = "2024" license = "MIT" readme = "README.md" From 664ad571b666cecea96a4e3a036dd0f2a5539b4f Mon Sep 17 00:00:00 2001 From: holger krekel Date: Thu, 14 May 2026 18:16:15 +0200 Subject: [PATCH 124/166] feat!: remove passthrough options that allowed unencrypted mail to pass see https://github.com/chatmail/relay/pull/970/ for the related removal of chatmail.ini options --- filtermail/src/config.rs | 24 +----------------------- filtermail/src/message.rs | 32 -------------------------------- filtermail/src/outbound.rs | 17 +++-------------- 3 files changed, 4 insertions(+), 69 deletions(-) diff --git a/filtermail/src/config.rs b/filtermail/src/config.rs index 4707d356..86c7f9d9 100644 --- a/filtermail/src/config.rs +++ b/filtermail/src/config.rs @@ -1,6 +1,6 @@ //! Configuration file handling for filtermail. -use serde::{Deserialize, Deserializer}; +use serde::Deserialize; use std::net::IpAddr; use std::num::NonZeroU32; use std::path::{Path, PathBuf}; @@ -30,10 +30,6 @@ pub struct Config { pub max_user_send_per_minute: NonZeroU32, #[serde(default = "Config::default_max_user_send_burst_size")] pub max_user_send_burst_size: NonZeroU32, - #[serde(default, deserialize_with = "deserialize_sequence")] - pub passthrough_senders: Vec, - #[serde(default, deserialize_with = "deserialize_sequence")] - pub passthrough_recipients: Vec, pub mail_domain: String, mailboxes_dir: Option, } @@ -44,22 +40,6 @@ struct ConfigWrapper { pub params: Config, } -/// Custom deserializer to parse space-separated strings into [`Vec`]. -fn deserialize_sequence<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let s: Option = Deserialize::deserialize(deserializer)?; - Ok(match s { - Some(v) => v - .split(' ') - .map(|item| item.trim().to_string()) - .filter(|item| !item.is_empty()) - .collect(), - None => Vec::new(), - }) -} - impl Config { /// Load configuration from a file. pub fn from_file(path: impl AsRef) -> Result { @@ -156,8 +136,6 @@ impl Default for Config { max_message_size: Self::default_max_message_size(), max_user_send_per_minute: Self::default_max_user_send_per_minute(), max_user_send_burst_size: Self::default_max_user_send_burst_size(), - passthrough_senders: Vec::new(), - passthrough_recipients: Vec::new(), mail_domain: "example.org".to_string(), mailboxes_dir: None, } diff --git a/filtermail/src/message.rs b/filtermail/src/message.rs index b9ce9fcf..da7c7a99 100644 --- a/filtermail/src/message.rs +++ b/filtermail/src/message.rs @@ -131,19 +131,6 @@ pub fn check_encrypted(mail: &mailparse::ParsedMail, outgoing: bool) -> bool { true } -/// Check if recipient matches a passthrough pattern -pub fn recipient_matches_passthrough(recipient: &str, passthrough_recipients: &[String]) -> bool { - for addr in passthrough_recipients { - if recipient == addr { - return true; - } - if addr.starts_with('@') && recipient.ends_with(addr) { - return true; - } - } - false -} - #[cfg(test)] mod tests { use super::*; @@ -151,11 +138,6 @@ mod tests { use rstest::*; use testresult::TestResult; - #[fixture] - fn passthrough_recipients() -> Vec { - vec!["pass@example.org".to_string(), "@example.com".to_string()] - } - #[rstest] #[case::asm("test_data/asm.eml", false)] #[case::encrypted("test_data/encrypted.eml", false)] @@ -189,18 +171,4 @@ mod tests { assert_eq!(check_encrypted(&parsed, false), expected); Ok(()) } - - #[rstest] - #[case("pass@example.org", true)] - #[case("other@example.org", false)] - #[case("anything@example.com", true)] - #[case("anything@sub.example.com", false)] - fn test_recipient_matches_passthrough( - #[case] recipient: &str, - #[case] expected: bool, - passthrough_recipients: Vec, - ) { - let result = recipient_matches_passthrough(recipient, &passthrough_recipients); - assert_eq!(result, expected); - } } diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 6b6adba0..d6e32fc5 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -2,7 +2,7 @@ use crate::ENCRYPTION_NEEDED_523; use crate::config::Config; -use crate::message::{check_encrypted, is_securejoin, recipient_matches_passthrough}; +use crate::message::{check_encrypted, is_securejoin}; use crate::smtp_client::SmtpConnectionPool; pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; @@ -114,11 +114,6 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { log::info!("Outgoing: Filtering unencrypted mail."); - // Allow passthrough senders - if self.config.passthrough_senders.contains(&from_addr) { - return Ok(()); - } - // Allow self-sent Autocrypt Setup Message if envelope.rcpt_to.len() == 1 && let Some(rcpt_to) = envelope.rcpt_to.first() @@ -133,14 +128,8 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { } } - for recipient in &envelope.rcpt_to { - if !recipient_matches_passthrough(recipient, &self.config.passthrough_recipients) { - log::warn!("Rejected unencrypted mail from: {from_addr}"); - return Err(ENCRYPTION_NEEDED_523.to_string()); - } - } - - Ok(()) + log::warn!("Rejected unencrypted mail from: {from_addr}"); + Err(ENCRYPTION_NEEDED_523.to_string()) } async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { From 847e19546b274765abf2fcae4ec01a77f5aeb7fe Mon Sep 17 00:00:00 2001 From: link2xt Date: Thu, 14 May 2026 21:28:56 +0200 Subject: [PATCH 125/166] fix: do not crash if accepting new connection fails If we run out of file descriptors, we cannot do anything to accept queued connections, so at least don't crash the process. Fixes . --- filtermail/src/http_server.rs | 27 ++++++++++++++++++--------- filtermail/src/smtp_server.rs | 27 ++++++++++++++++++--------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/filtermail/src/http_server.rs b/filtermail/src/http_server.rs index 513de58b..4335976c 100644 --- a/filtermail/src/http_server.rs +++ b/filtermail/src/http_server.rs @@ -8,6 +8,7 @@ use hyper_util::rt::TokioIo; use std::convert::Infallible; use std::pin::Pin; use std::sync::Arc; +use std::time::Duration; use tokio::net::{TcpListener, TcpStream}; /// Runs the HTTP server on the specified address with the given handler and maximum message size. @@ -21,17 +22,25 @@ where { let listener = TcpListener::bind(addr).await?; loop { - let (socket, _) = listener.accept().await?; + match listener.accept().await { + Ok((socket, _peer_addr)) => { + // Disable Nagle's algorithm. + socket.set_nodelay(true)?; - // Disable Nagle's algorithm. - socket.set_nodelay(true)?; - - let handler = handler.clone(); - tokio::spawn(async move { - if let Err(e) = handle_connection(socket, handler, max_size).await { - log::error!("Error handling connection: {e}"); + let handler = handler.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(socket, handler, max_size).await { + log::error!("Error handling connection: {e}"); + } + }); } - }); + Err(e) => { + log::error!("Error accepting connection: {e}"); + + // Sleep to avoid busy looping in case we ran into file descriptor limit. + tokio::time::sleep(Duration::from_secs(10)).await; + } + } } } diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index 818d9eb3..8651105a 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -4,6 +4,7 @@ use crate::utils::{extract_address, log_eml}; use async_trait::async_trait; use memchr::{Memchr, memmem}; use std::sync::Arc; +use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; use tokio::net::{TcpListener, TcpStream}; @@ -110,17 +111,25 @@ where log::info!("entering serving loop"); loop { - let (socket, _) = listener.accept().await?; + match listener.accept().await { + Ok((socket, _peer_addr)) => { + // Disable Nagle's algorithm. + socket.set_nodelay(true)?; - // Disable Nagle's algorithm. - socket.set_nodelay(true)?; - - let handler = handler.clone(); - tokio::spawn(async move { - if let Err(e) = handle_connection(socket, handler, max_size).await { - log::error!("Error handling connection: {e}"); + let handler = handler.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(socket, handler, max_size).await { + log::error!("Error handling connection: {e}"); + } + }); } - }); + Err(e) => { + log::error!("Error accepting connection: {e}"); + + // Sleep to avoid busy looping in case we ran into file descriptor limit. + tokio::time::sleep(Duration::from_secs(10)).await; + } + } } } From c84a96ac78d55c32de0c11f38e5ebd1bcf295177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 26 May 2026 02:25:51 +0900 Subject: [PATCH 126/166] docs(readme): Remove docs for options removed in 664ad57 (#162) --- filtermail/README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/filtermail/README.md b/filtermail/README.md index e3b14337..ae28b877 100644 --- a/filtermail/README.md +++ b/filtermail/README.md @@ -99,9 +99,7 @@ for messages received from clients and performs following steps: 4. Rejects messages that do not meet at least one of the following criteria: - PGP encrypted, - securejoin message, - - sender is in `passthrough_senders`, - self-sent Autocrypt Setup Message, - - all recipients match `passthrough_recipients`. ### Transport mode @@ -158,11 +156,6 @@ but implements a custom parser that only requires a small subset of configuratio defaults to `60`. - `max_user_send_burst_size` - per-user max burst size for sending rate limiting (GCRA bucket capacity), defaults to `10`. -- `passthrough_senders` - space separated list of email addresses - which can send outbound un-encrypted mail. -- `passthrough_recipients` - space separated list of email addresses - which can receive inbound un-encrypted mail, - item may start with `@` to whitelist whole recipient domains. - `mail_domain` - domain name used in email addresses. - `mailboxes_dir` - path to mailboxes directory, defaults to `/home/vmail/mail/`. From febfb90510e95a848167707181deb9f6a2b6022a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 26 May 2026 13:56:00 +0900 Subject: [PATCH 127/166] chore(release): prepare for 0.7.0 (#163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 13 +++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index b16550a1..50204ada 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,16 @@ +## 0.7.0 - 2026-05-26 + +### Bug Fixes + +- Do not crash if accepting new connection fails + +### Documentation + +- *(readme)* Remove docs for options removed in da9a116 (#162) + +### Features + +- [**breaking**] Remove passthrough options that allowed unencrypted mail to pass ## 0.6.6 - 2026-05-12 ### Bug Fixes diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 904ad901..4ae81fe6 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -508,7 +508,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.6.6" +version = "0.7.0" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 080ad35f..981f4eb9 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.6.6" +version = "0.7.0" edition = "2024" license = "MIT" readme = "README.md" From a6361a21941b18241f79f25bf4954c5cb7ed7fbe Mon Sep 17 00:00:00 2001 From: l Date: Mon, 1 Jun 2026 07:51:33 +0000 Subject: [PATCH 128/166] fix: switch ratelimiter to MonotonicClock (#167) There is a known bug that `quanta` clock used by default sometimes jumps back: . This results in `governor` rate limiter incorrectly rejecting the messages even when less than the configured burst size has been sent. Switched to standard monotonic clock which does not have this problem. I tested that this fixed the problem for me, chatmail relay test `test_exceed_rate_limit` stopped failing randomly. Fixes --- filtermail/Cargo.lock | 61 -------------------------------------- filtermail/Cargo.toml | 5 +++- filtermail/src/outbound.rs | 22 ++++++++++++-- 3 files changed, 23 insertions(+), 65 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 4ae81fe6..4c547417 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -676,11 +676,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -714,13 +712,10 @@ dependencies = [ "futures-sink", "futures-timer", "futures-util", - "getrandom 0.3.4", "hashbrown 0.16.1", "nonzero_ext", "parking_lot", "portable-atomic", - "quanta", - "rand 0.9.2", "smallvec", "spinning_top", "web-time", @@ -1594,21 +1589,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quanta" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" -dependencies = [ - "crossbeam-utils", - "libc", - "once_cell", - "raw-cpuid", - "wasi", - "web-sys", - "winapi", -] - [[package]] name = "quote" version = "1.0.43" @@ -1711,15 +1691,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "raw-cpuid" -version = "11.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" -dependencies = [ - "bitflags", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -2577,16 +2548,6 @@ dependencies = [ "semver", ] -[[package]] -name = "web-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "web-time" version = "1.1.0" @@ -2612,22 +2573,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -2637,12 +2582,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-link" version = "0.2.1" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 981f4eb9..2d826982 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -19,7 +19,10 @@ mailparse = "0.16.1" memchr = "2.8.0" log = "0.4.29" env_logger = "0.11.10" -governor = "0.10.4" +governor = { version = "0.10.4", default-features = false, features = [ + "std", + "dashmap", +] } viadkim = { version = "0.2.0" } hickory-resolver = { version = "0.26.1", features = ["dnssec-ring"] } lru = "0.17.0" diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index d6e32fc5..cacf6e52 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -8,7 +8,9 @@ pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; use crate::utils::{build_resolver, extract_address}; use async_trait::async_trait; -use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; +use governor::clock::MonotonicClock; +use governor::middleware::NoOpMiddleware; +use governor::{Quota, RateLimiter}; use hickory_resolver::TokioResolver; use mailparse::{MailHeaderMap, parse_mail}; use std::sync::Arc; @@ -17,7 +19,20 @@ use std::sync::Arc; pub struct OutgoingBeforeQueueHandler { config: Config, dns_resolver: Arc, - send_rate_limiter: DefaultKeyedRateLimiter, + + // We explicitly use standard MonotonicClock here. + // governor 0.10.4 by default uses "quanta" clock + // if the feature "quanta" is enabled + // and it has a known problem + // of sometimes jumping back in time + // when moved between CPU cores: + // + send_rate_limiter: RateLimiter< + String, + governor::state::keyed::DashMapStateStore, + MonotonicClock, + NoOpMiddleware, + >, smtp_connection_pool: Arc, } @@ -26,10 +41,11 @@ impl OutgoingBeforeQueueHandler { let quota = Quota::per_minute(config.max_user_send_per_minute) .allow_burst(config.max_user_send_burst_size); let dns_resolver = Arc::new(build_resolver()?); + let send_rate_limiter = RateLimiter::dashmap_with_clock(quota, MonotonicClock); Ok(Self { config, dns_resolver, - send_rate_limiter: RateLimiter::keyed(quota), + send_rate_limiter, smtp_connection_pool: SmtpConnectionPool::new(), }) } From 893ceab10717f32a463bedec3f3ade2d93f7bb1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:51:57 +0200 Subject: [PATCH 129/166] chore(deps): Bump taiki-e/install-action from 2.75.28 to 2.79.6 (#164) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.28 to 2.79.6. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/51cd0b8c0499559d9a4d75c0f5c67bec3a894ec8...f48d2f8ba2b452934c948b7be1a768079c3632ff) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.79.6 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 418a1786..e21d21db 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - - uses: taiki-e/install-action@51cd0b8c0499559d9a4d75c0f5c67bec3a894ec8 # v2 + - uses: taiki-e/install-action@f48d2f8ba2b452934c948b7be1a768079c3632ff # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index 061bbdf5..c0f29d2b 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@51cd0b8c0499559d9a4d75c0f5c67bec3a894ec8 # v2 + - uses: taiki-e/install-action@f48d2f8ba2b452934c948b7be1a768079c3632ff # v2 with: tool: cargo-zigbuild - name: Build From e9a63222e6f2bdb977b4e14edf059947bdb4ecb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Mon, 1 Jun 2026 23:27:30 +0900 Subject: [PATCH 130/166] fix(smtp-server): Correct error when EOF while reading DATA (#168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log `Unexpected EoF while receiving DATA!` instead of `Malformed DATA line without CRLF ending!` Fixes: #165 Signed-off-by: Jagoda Ślązak --- filtermail/src/smtp_server.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index 8651105a..f2ff8109 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -225,7 +225,10 @@ where let mut data_line = String::new(); 'data_read: loop { data_line.clear(); - reader.read_line(&mut data_line).await?; + if reader.read_line(&mut data_line).await? == 0 { + log::warn!("Unexpected EoF while receiving DATA! Closing connection."); + break 'connection; + } if data_line == ".\r\n" { break 'data_read; From 6d6517f8f3a80b95fcafdf7bd2a817ca0faca769 Mon Sep 17 00:00:00 2001 From: j4n Date: Mon, 1 Jun 2026 21:51:34 +0200 Subject: [PATCH 131/166] chore: add filtermail.mtail so filtermail failures can be monitored (#169) Example Prometheus queries: Outbound errors by reason: sum by (reason) (rate(filtermail_transport_error_total[5m])) filtermail-incoming drops by reason: sum by (reason) (rate(filtermail_inbound_drop_total[5m])) --- filtermail/contrib/filtermail.mtail | 101 ++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 filtermail/contrib/filtermail.mtail diff --git a/filtermail/contrib/filtermail.mtail b/filtermail/contrib/filtermail.mtail new file mode 100644 index 00000000..bc72c22f --- /dev/null +++ b/filtermail/contrib/filtermail.mtail @@ -0,0 +1,101 @@ +# filtermail.mtail: filtermail process event counters + +# +# Counters +# + +# Connection errors in the transport path +# Error::Io / Error::ConnectionFailed / Error::Tls: the reason string filtermail strips +counter filtermail_transport_error_total by reason + +# Unexpected transport errors: DNS failure, HTTP error, config error. +counter filtermail_transport_unexpected_total + +# Silent drops: inbound +# "unencrypted" overlaps with rejected_unencrypted_mail_count in delivered_mail.mtail. +counter filtermail_inbound_drop_total by reason + +# Silent drops: outbound +# "unencrypted" overlaps with rejected_unencrypted_mail_count in delivered_mail.mtail; +# "sender_disabled" and "all_recipients_disabled" are new. +counter filtermail_outbound_drop_total by reason + +# Reinject failures +counter filtermail_reinject_error_total by direction + +# SMTP-level connection errors (client dropped mid-session) +counter filtermail_smtp_error_total by reason + +# +# filtermail::transport +# + +/filtermail\[\d+\]: \[WARN\s+filtermail::transport\] Connection error relaying to mail server \S+: / { + /timed out/ { + filtermail_transport_error_total["connection_timeout"]++ + } otherwise { + /Failed to connect to any of the following addresses/ { + filtermail_transport_error_total["connection_refused"]++ + } otherwise { + /Connection refused/ { + filtermail_transport_error_total["connection_refused"]++ + } otherwise { + /invalid certificate/ { + filtermail_transport_error_total["tls_cert_not_verified"]++ + } otherwise { + /peer sent fatal alert|handshake/ { + filtermail_transport_error_total["tls_handshake"]++ + } otherwise { + /close_notify|connection closed/ { + filtermail_transport_error_total["lost_connection"]++ + } otherwise { + filtermail_transport_error_total["other"]++ + }}}}}}} + +/filtermail\[\d+\]: \[WARN\s+filtermail::transport\] Unexpected error while delivering/ { + filtermail_transport_unexpected_total++ +} + +# +# filtermail::smtp_server +# + +/filtermail(?:-incoming)?\[\d+\]: \[WARN\s+filtermail::smtp_server\] Unexpected EoF while receiving DATA/ { + filtermail_smtp_error_total["unexpected_eof"]++ +} + +/filtermail(?:-incoming)?\[\d+\]: \[WARN\s+filtermail::smtp_server\] Malformed DATA line without CRLF/ { + filtermail_smtp_error_total["malformed_data_line"]++ +} + +# +# filtermail::outbound +# + +/filtermail\[\d+\]: \[WARN\s+filtermail::outbound\] Rejected unencrypted mail/ { + filtermail_outbound_drop_total["unencrypted"]++ +} + +/filtermail\[\d+\]: \[WARN\s+filtermail::outbound\] Dropping mail; Sender .* is disabled/ { + filtermail_outbound_drop_total["sender_disabled"]++ +} + +/filtermail\[\d+\]: \[WARN\s+filtermail::outbound\] Dropping mail; All recipients disabled/ { + filtermail_outbound_drop_total["all_recipients_disabled"]++ +} + +/filtermail\[\d+\]: \[WARN\s+filtermail::outbound\] Failed to re.inject mail/ { + filtermail_reinject_error_total["outbound"]++ +} + +# +# filtermail::inbound +# + +/filtermail(?:-incoming)?\[\d+\]: \[WARN\s+filtermail::inbound\] Rejected unencrypted mail/ { + filtermail_inbound_drop_total["unencrypted"]++ +} + +/filtermail(?:-incoming)?\[\d+\]: \[WARN\s+filtermail::inbound\] Failed to re.inject mail/ { + filtermail_reinject_error_total["inbound"]++ +} From 8a60245183ffa013097adbd895909abe2cea5c12 Mon Sep 17 00:00:00 2001 From: Andrey 0xdc09 <50486086+DarkCat09@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:04:32 +0000 Subject: [PATCH 132/166] fix(resolver): disable negative caching (#170) --- filtermail/src/utils.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/filtermail/src/utils.rs b/filtermail/src/utils.rs index 78d38ff5..f8a3ef64 100644 --- a/filtermail/src/utils.rs +++ b/filtermail/src/utils.rs @@ -1,8 +1,10 @@ +use hickory_resolver::config::ResolverOpts; use hickory_resolver::{TokioResolver, proto::dnssec::TrustAnchors}; use mailparse::MailAddr; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; +use std::time::Duration; /// Extracts the first email address found in SMTP command or email header. /// @@ -106,10 +108,14 @@ pub async fn log_eml(reason: &str, data: &[u8]) -> Result Result { - let dns_resolver = TokioResolver::builder_tokio()? + let mut builder = TokioResolver::builder_tokio()? // https://github.com/hickory-dns/hickory-dns/issues/3519 - .with_trust_anchor(Arc::new(TrustAnchors::default())) - .build()?; + .with_trust_anchor(Arc::new(TrustAnchors::default())); + + // disable negative caching to prevent possible federation problems + builder.options_mut().negative_max_ttl = Some(Duration::ZERO); + + let dns_resolver = builder.build()?; assert!( dns_resolver.options().validate, From f82f34d054e49242a457b2e2b8d73254ed24bc60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:56:45 +0200 Subject: [PATCH 133/166] chore(deps): Bump tokio from 1.52.1 to 1.52.3 (#172) Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.52.1 to 1.52.3. - [Release notes](https://github.com/tokio-rs/tokio/releases) - [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.1...tokio-1.52.3) --- updated-dependencies: - dependency-name: tokio dependency-version: 1.52.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/Cargo.lock | 4 ++-- filtermail/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 4c547417..a1bed8df 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -2197,9 +2197,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 2d826982..044682a3 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -13,7 +13,7 @@ async-trait = "0.1.89" base64 = "0.22.1" serde = { version = "1.0.228", features = ["derive"] } serini = "0.2.2" -tokio = { version = "1.52.1", features = ["full"] } +tokio = { version = "1.52.3", features = ["full"] } thiserror = "2.0.18" mailparse = "0.16.1" memchr = "2.8.0" From 2538caa1b29844eef316e72e496a223166a01d04 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:57:12 +0200 Subject: [PATCH 134/166] chore(deps): Bump lru from 0.17.0 to 0.18.0 (#171) Bumps [lru](https://github.com/jeromefroe/lru-rs) from 0.17.0 to 0.18.0. - [Changelog](https://github.com/jeromefroe/lru-rs/blob/master/CHANGELOG.md) - [Commits](https://github.com/jeromefroe/lru-rs/compare/0.17.0...0.18.0) --- updated-dependencies: - dependency-name: lru dependency-version: 0.18.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/Cargo.lock | 4 ++-- filtermail/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index a1bed8df..8d81888f 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -1279,9 +1279,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0b564323a0fb6d54b864f625ae139de9612e27edb944dda37c109f05aac531" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" dependencies = [ "hashbrown 0.17.0", ] diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 044682a3..ff97829b 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -25,7 +25,7 @@ governor = { version = "0.10.4", default-features = false, features = [ ] } viadkim = { version = "0.2.0" } hickory-resolver = { version = "0.26.1", features = ["dnssec-ring"] } -lru = "0.17.0" +lru = "0.18.0" parking_lot = "0.12.5" tokio-rustls = { version = "0.26.4", default-features = false, features = [ "ring", From b0214a71d75ad7103a51ba75ce6c9c4280d3a27e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:58:35 +0000 Subject: [PATCH 135/166] chore(deps): Bump memchr from 2.8.0 to 2.8.1 Bumps [memchr](https://github.com/BurntSushi/memchr) from 2.8.0 to 2.8.1. - [Commits](https://github.com/BurntSushi/memchr/compare/2.8.0...2.8.1) --- updated-dependencies: - dependency-name: memchr dependency-version: 2.8.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- filtermail/Cargo.lock | 4 ++-- filtermail/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 8d81888f..f3196921 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -1308,9 +1308,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mio" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index ff97829b..b9149d67 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -16,7 +16,7 @@ serini = "0.2.2" tokio = { version = "1.52.3", features = ["full"] } thiserror = "2.0.18" mailparse = "0.16.1" -memchr = "2.8.0" +memchr = "2.8.1" log = "0.4.29" env_logger = "0.11.10" governor = { version = "0.10.4", default-features = false, features = [ From 939e5367c9cf2e04248a37dfb460fbd110471cb9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:23:51 +0000 Subject: [PATCH 136/166] chore(deps): Bump taiki-e/install-action from 2.79.6 to 2.79.14 Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.79.6 to 2.79.14. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/f48d2f8ba2b452934c948b7be1a768079c3632ff...873c7452cadb7c034694a1282227095d93fbdf92) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.79.14 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index e21d21db..ba24cce6 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - - uses: taiki-e/install-action@f48d2f8ba2b452934c948b7be1a768079c3632ff # v2 + - uses: taiki-e/install-action@873c7452cadb7c034694a1282227095d93fbdf92 # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index c0f29d2b..364df241 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@f48d2f8ba2b452934c948b7be1a768079c3632ff # v2 + - uses: taiki-e/install-action@873c7452cadb7c034694a1282227095d93fbdf92 # v2 with: tool: cargo-zigbuild - name: Build From fb5a25939ce28d9c2725844703de89ef88f5889f Mon Sep 17 00:00:00 2001 From: l Date: Sat, 6 Jun 2026 07:55:18 +0000 Subject: [PATCH 137/166] fix: ignore CNAME records when resolving TXT records (#177) --- filtermail/src/dkim_verifier.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/filtermail/src/dkim_verifier.rs b/filtermail/src/dkim_verifier.rs index 7fc62938..6b8a42aa 100644 --- a/filtermail/src/dkim_verifier.rs +++ b/filtermail/src/dkim_verifier.rs @@ -95,6 +95,12 @@ impl LookupTxt for CachedResolver { lookup .answers() .iter() + .filter(|record| { + // Select only TXT records. + // When resolving TXT query, CNAMEs are also returned as answers. + // We want to filter out CNAMEs first. + matches!(record.data, hickory_resolver::proto::rr::RData::TXT(_)) + }) // We don't check all records, as this can be a DoS attack vector. // In theory, selector domains should only have a single TXT record. // In practice, we check at most 3, just in case of weird configuration. From 73b684f90ca99e41103d241cb8a8c2c17b736c46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:20:04 +0200 Subject: [PATCH 138/166] chore(deps): Bump log from 0.4.29 to 0.4.30 (#173) Bumps [log](https://github.com/rust-lang/log) from 0.4.29 to 0.4.30. - [Release notes](https://github.com/rust-lang/log/releases) - [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md) - [Commits](https://github.com/rust-lang/log/compare/0.4.29...0.4.30) --- updated-dependencies: - dependency-name: log dependency-version: 0.4.30 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/Cargo.lock | 4 ++-- filtermail/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index f3196921..309ed2b4 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -1273,9 +1273,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lru" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index b9149d67..bf396c12 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -17,7 +17,7 @@ tokio = { version = "1.52.3", features = ["full"] } thiserror = "2.0.18" mailparse = "0.16.1" memchr = "2.8.1" -log = "0.4.29" +log = "0.4.30" env_logger = "0.11.10" governor = { version = "0.10.4", default-features = false, features = [ "std", From d3f5412420ea78c340968d3372db31759cd20d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Sat, 6 Jun 2026 22:28:53 +0900 Subject: [PATCH 139/166] feat: switch to aws-lc-rs cryptography provider (#178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches crypto provider to keep chatmail codebase consistent, analogous to https://github.com/chatmail/core/pull/8313 Signed-off-by: Jagoda Ślązak --- filtermail/Cargo.lock | 72 ++++++++++++++++++++++++++++++++++-- filtermail/Cargo.toml | 6 +-- filtermail/src/main.rs | 2 +- filtermail/src/tls.rs | 15 +++++--- filtermail/src/tls/danger.rs | 6 +-- filtermail/src/transport.rs | 14 ++++--- filtermail/src/utils.rs | 1 - 7 files changed, 92 insertions(+), 24 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 309ed2b4..a97c06b3 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -125,6 +125,29 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.22.1" @@ -171,6 +194,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -201,6 +226,15 @@ dependencies = [ "encoding_rs", ] +[[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.4" @@ -395,6 +429,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ed25519" version = "2.2.3" @@ -570,6 +610,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.31" @@ -796,6 +842,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" dependencies = [ "async-trait", + "aws-lc-rs", "bitflags", "cfg-if", "data-encoding", @@ -809,7 +856,6 @@ dependencies = [ "lru-cache", "parking_lot", "rand 0.10.1", - "ring", "rustls-pki-types", "thiserror", "time", @@ -825,6 +871,7 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" dependencies = [ + "aws-lc-rs", "bitflags", "data-encoding", "idna 1.1.0", @@ -1207,6 +1254,16 @@ dependencies = [ "syn", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -1764,7 +1821,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -1845,9 +1902,9 @@ version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ + "aws-lc-rs", "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1869,9 +1926,10 @@ version = "0.103.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2362,6 +2420,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index bf396c12..3f81a347 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -24,11 +24,11 @@ governor = { version = "0.10.4", default-features = false, features = [ "dashmap", ] } viadkim = { version = "0.2.0" } -hickory-resolver = { version = "0.26.1", features = ["dnssec-ring"] } +hickory-resolver = { version = "0.26.1", features = ["dnssec-aws-lc-rs"] } lru = "0.18.0" parking_lot = "0.12.5" tokio-rustls = { version = "0.26.4", default-features = false, features = [ - "ring", + "aws-lc-rs", "logging", "tls12", ] } @@ -44,7 +44,7 @@ hyper-util = { version = "0.1.20", features = [ ] } http-body-util = "0.1.3" hyper-rustls = { version = "0.27.9", default-features = false, features = [ - "ring", + "aws-lc-rs", "http1", "http2", "logging", diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index 072e559c..165e8802 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -81,7 +81,7 @@ async fn main() -> Result<(), error::Error> { .format_timestamp(None) .init(); - tokio_rustls::rustls::crypto::ring::default_provider() + tokio_rustls::rustls::crypto::aws_lc_rs::default_provider() .install_default() .expect("Failed to set up rustls crypto provider."); diff --git a/filtermail/src/tls.rs b/filtermail/src/tls.rs index bb13de0d..a9b5af1f 100644 --- a/filtermail/src/tls.rs +++ b/filtermail/src/tls.rs @@ -16,7 +16,7 @@ pub async fn wrap_rustls( where IO: AsyncRead + AsyncWrite + Unpin, { - let config = configure_rustls(resumption_store, dangerous_no_cert_verification); + let config = configure_rustls(resumption_store, dangerous_no_cert_verification)?; let tls = tokio_rustls::TlsConnector::from(Arc::new(config)); let name = rustls::pki_types::ServerName::try_from(hostname)?.to_owned(); let tls_stream = tls.connect(name, stream).await?; @@ -26,13 +26,16 @@ where pub fn configure_rustls( resumption_store: Arc, dangerous_no_cert_verification: bool, -) -> rustls::ClientConfig { +) -> Result { let root_cert_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - let mut config = rustls::ClientConfig::builder() - .with_root_certificates(root_cert_store) - .with_no_client_auth(); + let mut config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::aws_lc_rs::default_provider(), + )) + .with_safe_default_protocol_versions()? + .with_root_certificates(root_cert_store) + .with_no_client_auth(); // Enable TLS 1.3 session resumption // as defined in . @@ -50,5 +53,5 @@ pub fn configure_rustls( .set_certificate_verifier(Arc::new(NoCertificateVerification::default())); } - config + Ok(config) } diff --git a/filtermail/src/tls/danger.rs b/filtermail/src/tls/danger.rs index 1254fcf4..1b9a9ffd 100644 --- a/filtermail/src/tls/danger.rs +++ b/filtermail/src/tls/danger.rs @@ -24,7 +24,7 @@ impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification { cert: &CertificateDer<'_>, dss: &rustls::DigitallySignedStruct, ) -> Result { - let provider = rustls::crypto::ring::default_provider(); + let provider = rustls::crypto::aws_lc_rs::default_provider(); let supported_schemes = &provider.signature_verification_algorithms; rustls::crypto::verify_tls12_signature(message, cert, dss, supported_schemes) } @@ -35,13 +35,13 @@ impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification { cert: &CertificateDer<'_>, dss: &rustls::DigitallySignedStruct, ) -> Result { - let provider = rustls::crypto::ring::default_provider(); + let provider = rustls::crypto::aws_lc_rs::default_provider(); let supported_schemes = &provider.signature_verification_algorithms; rustls::crypto::verify_tls13_signature(message, cert, dss, supported_schemes) } fn supported_verify_schemes(&self) -> Vec { - let provider = rustls::crypto::ring::default_provider(); + let provider = rustls::crypto::aws_lc_rs::default_provider(); provider .signature_verification_algorithms .supported_schemes() diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs index cda9442e..06fd4d62 100644 --- a/filtermail/src/transport.rs +++ b/filtermail/src/transport.rs @@ -38,8 +38,10 @@ struct HttpsClient { impl HttpsClient { /// Creates a new `[HttpsClient]`. - pub fn new(tls_resumption_store: Arc) -> Self { - let tls_client_config = tls::configure_rustls(tls_resumption_store.clone(), false); + pub fn new( + tls_resumption_store: Arc, + ) -> Result { + let tls_client_config = tls::configure_rustls(tls_resumption_store.clone(), false)?; let https_connector = hyper_rustls::HttpsConnectorBuilder::new() .with_tls_config(tls_client_config) .https_only() @@ -50,7 +52,7 @@ impl HttpsClient { hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) .build(https_connector); - let tls_client_config_relaxed = tls::configure_rustls(tls_resumption_store, true); + let tls_client_config_relaxed = tls::configure_rustls(tls_resumption_store, true)?; let https_connector_relaxed = hyper_rustls::HttpsConnectorBuilder::new() .with_tls_config(tls_client_config_relaxed) .https_only() @@ -61,10 +63,10 @@ impl HttpsClient { hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) .build(https_connector_relaxed); - Self { + Ok(Self { secure: https_client, relaxed: https_client_relaxed, - } + }) } } @@ -82,7 +84,7 @@ impl TransportHandler { pub fn new(config: Config) -> Result { let dns_resolver = Arc::new(build_resolver()?); let tls_resumption_store = Arc::new(rustls::client::ClientSessionMemoryCache::new(256)); - let https_client = HttpsClient::new(tls_resumption_store.clone()); + let https_client = HttpsClient::new(tls_resumption_store.clone())?; let mxdeliv_cache = Arc::new(retainer::Cache::new()); let mxdeliv_cache_clone = mxdeliv_cache.clone(); diff --git a/filtermail/src/utils.rs b/filtermail/src/utils.rs index f8a3ef64..df0b0231 100644 --- a/filtermail/src/utils.rs +++ b/filtermail/src/utils.rs @@ -1,4 +1,3 @@ -use hickory_resolver::config::ResolverOpts; use hickory_resolver::{TokioResolver, proto::dnssec::TrustAnchors}; use mailparse::MailAddr; use std::path::PathBuf; From 81aac3cb773be87a85802d41270d89f46821f896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:45:03 +0900 Subject: [PATCH 140/166] chore(release): prepare for 0.7.1 (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 16 ++++++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index 50204ada..fe850828 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,19 @@ +## 0.7.1 - 2026-06-09 + +### Bug Fixes + +- Switch ratelimiter to MonotonicClock (#167) +- *(smtp-server)* Correct error when EOF while reading DATA (#168) +- *(resolver)* Disable negative caching (#170) +- Ignore CNAME records when resolving TXT records (#177) + +### Features + +- Switch to aws-lc-rs cryptography provider (#178) + +### Miscellaneous Tasks + +- Add filtermail.mtail so filtermail failures can be monitored (#169) ## 0.7.0 - 2026-05-26 ### Bug Fixes diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index a97c06b3..8bce1434 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -548,7 +548,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.7.0" +version = "0.7.1" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 3f81a347..6d108c27 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.7.0" +version = "0.7.1" edition = "2024" license = "MIT" readme = "README.md" From 488bbcb1829fced9932fa4d5080093379a363b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Thu, 11 Jun 2026 05:31:53 +0900 Subject: [PATCH 141/166] docs(readme): Disable colors in mermaid diagrams (#180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously used colors caused diagrams to be unreadable in light mode. Removing styling completely, as this was redundant anyway. Signed-off-by: Jagoda Ślązak --- filtermail/README.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/filtermail/README.md b/filtermail/README.md index ae28b877..b8759fd2 100644 --- a/filtermail/README.md +++ b/filtermail/README.md @@ -34,12 +34,6 @@ flowchart LR end mta[Sender's relay] -.SMTP :25.-> smtpd1 mta -.HTTPS /mxdeliv.-> nginx - - style postfix fill:#363 - style smtpd1 fill:#252 - style smtpd2 fill:#252 - style queue fill:#252 - style filtermail fill:#225 ``` Filtermail in incoming mode acts as a proxy filter @@ -82,12 +76,6 @@ flowchart LR open-dkim[OpenDKIM] <--milter--> smtpd2 end client[Client] --SMTP :587--> smtpd1 - - style postfix fill:#363 - style smtpd1 fill:#252 - style smtpd2 fill:#252 - style queue fill:#252 - style filtermail fill:#225 ``` Filtermail in outgoing mode acts as a proxy filter @@ -115,11 +103,6 @@ flowchart LR end filtermail -.SMTP :25.-> mta[Recipient's relay] filtermail -.HTTPS /mxdeliv.-> mta - - style postfix fill:#363 - style qmgr fill:#252 - style lmtp fill:#252 - style filtermail fill:#225 ``` Filtermail in transport mode is used for final delivery to remote MTAs. From a66887b778de2a2aacdaae2aac76882907d14579 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:41:54 +0200 Subject: [PATCH 142/166] chore(deps): Bump actions/checkout from 6.0.2 to 6.0.3 (#181) Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 6.0.3. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 8 ++++---- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index ba24cce6..bf3b53f7 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: name: Check Formatting runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master @@ -37,7 +37,7 @@ jobs: name: Check Unused Dependencies runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - uses: bnjbvr/cargo-machete@ac30a525c0a8d163a92d727b3ff079ee3f6ecb08 # v0.9.2 @@ -51,7 +51,7 @@ jobs: toolchain: ["stable", "beta"] continue-on-error: ${{ matrix.toolchain == 'beta' }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master @@ -67,7 +67,7 @@ jobs: name: Run rust tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index 364df241..dcc45b8d 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -7,7 +7,7 @@ jobs: name: Build binaries. runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master From b36856a383e16943e75b0e66b3e95060126b90af Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:42:27 +0200 Subject: [PATCH 143/166] chore(deps): Bump taiki-e/install-action from 2.79.14 to 2.81.6 (#182) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.79.14 to 2.81.6. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/873c7452cadb7c034694a1282227095d93fbdf92...59012be0884e296ca2da49b530610e72c49039ad) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.81.6 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index bf3b53f7..f2f82614 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - - uses: taiki-e/install-action@873c7452cadb7c034694a1282227095d93fbdf92 # v2 + - uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index dcc45b8d..9729bded 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@873c7452cadb7c034694a1282227095d93fbdf92 # v2 + - uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2 with: tool: cargo-zigbuild - name: Build From 7d0db2c46e886e79fb908a182bf87e17e72f47c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:29:12 +0200 Subject: [PATCH 144/166] chore(deps): Bump taiki-e/install-action from 2.81.6 to 2.81.10 (#183) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.81.6 to 2.81.10. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/59012be0884e296ca2da49b530610e72c49039ad...7a79fe8c3a13344501c80d99cae481c1c9085912) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.81.10 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index f2f82614..729b52e9 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - - uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2 + - uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index 9729bded..e2ba13de 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2 + - uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2 with: tool: cargo-zigbuild - name: Build From 7ea7904ea093b1f213425bff59749d3375542d58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Tue, 12 May 2026 07:13:20 +0200 Subject: [PATCH 145/166] feat(transport): Destination worker pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a per-destination worker pool, so that connections to the same destination are not parallelized, but instead queued. If a queue is full, new messages are immediately deferred, before mail data is sent from postfix. Closes: #141 Signed-off-by: Jagoda Ślązak --- filtermail/src/http_server.rs | 50 ++- filtermail/src/inbound.rs | 49 +- filtermail/src/main.rs | 3 +- filtermail/src/outbound.rs | 62 ++- filtermail/src/smtp_responses.rs | 7 + filtermail/src/smtp_server.rs | 140 +++--- filtermail/src/transport.rs | 540 ++++++++--------------- filtermail/src/transport/https_client.rs | 57 +++ filtermail/src/transport/worker.rs | 447 +++++++++++++++++++ 9 files changed, 881 insertions(+), 474 deletions(-) create mode 100644 filtermail/src/smtp_responses.rs create mode 100644 filtermail/src/transport/https_client.rs create mode 100644 filtermail/src/transport/worker.rs diff --git a/filtermail/src/http_server.rs b/filtermail/src/http_server.rs index 4335976c..d9115398 100644 --- a/filtermail/src/http_server.rs +++ b/filtermail/src/http_server.rs @@ -1,4 +1,4 @@ -use crate::smtp_server::{Envelope, SmtpHandler}; +use crate::smtp_server::{SmtpHandler, Transaction}; use http_body_util::combinators::BoxBody; use http_body_util::{BodyExt, Full}; use hyper::body::{Bytes, Incoming}; @@ -93,6 +93,8 @@ impl Service> for MxDelivService )?); } + let mut transaction = Transaction::default(); + let mail_from = req .headers() .get(crate::transport::HEADER_MAIL_FROM) @@ -100,16 +102,14 @@ impl Service> for MxDelivService .unwrap_or("") .to_string(); - match handler.handle_mail(&mail_from) { - Ok(_) => {} - Err(e) => { - return Ok(Response::builder() - .status(400) - .body(Full::new(Bytes::from(e)).boxed())?); - } - }; + if let Err(e) = handler.handle_mail_from(&mail_from) { + return Ok(Response::builder() + .status(400) + .body(Full::new(Bytes::from(e)).boxed())?); + } + transaction.envelope.mail_from = mail_from; - let rcpt_to = req + let rcpt_to: Vec = req .headers() .get_all(crate::transport::HEADER_RCPT_TO) .iter() @@ -117,6 +117,21 @@ impl Service> for MxDelivService .map(ToString::to_string) .collect(); + for r in &rcpt_to { + if let Err(e) = handler.handle_rcpt_to(r, &mut transaction) { + return Ok(Response::builder() + .status(400) + .body(Full::new(Bytes::from(e)).boxed())?); + } + } + transaction.envelope.rcpt_to = rcpt_to; + + if let Err(e) = handler.handle_data_start(&transaction) { + return Ok(Response::builder() + .status(400) + .body(Full::new(Bytes::from(e)).boxed())?); + } + let body_limited = http_body_util::Limited::new(req.into_body(), max_size); let body_bytes = match body_limited.collect().await { Ok(body) => body.to_bytes(), @@ -127,24 +142,19 @@ impl Service> for MxDelivService } }; - let mut envelope = Envelope { - origin_ip: "".to_string(), - mail_from, - rcpt_to, - data: body_bytes.to_vec(), - }; + transaction.envelope.data = body_bytes.to_vec(); - log::debug!("(HTTP) MAIL FROM:<{}>", envelope.mail_from); - for rcpt in &envelope.rcpt_to { + log::debug!("(HTTP) MAIL FROM:<{}>", transaction.envelope.mail_from); + for rcpt in &transaction.envelope.rcpt_to { log::debug!("(HTTP) RCPT TO:<{}>", rcpt); } log::trace!( "(HTTP) DATA:\n{:?}", - String::from_utf8_lossy(&envelope.data) + String::from_utf8_lossy(&transaction.envelope.data) ); - match handler.handle_data(&mut envelope).await { + match handler.handle_data_dot(&mut transaction).await { Ok(response) => Ok(Response::builder() .status(200) .body(Full::new(Bytes::from(response)).boxed())?), diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index dc9dbf7b..9328e58d 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -1,12 +1,12 @@ //! Module for handling incoming SMTP messages. -use crate::ENCRYPTION_NEEDED_523; use crate::config::Config; use crate::dkim_verifier::DkimVerifier; use crate::message::{check_encrypted, is_securejoin}; use crate::smtp_client::SmtpConnectionPool; +use crate::smtp_responses::ENCRYPTION_NEEDED_523; pub use crate::smtp_server::Envelope; -use crate::smtp_server::SmtpHandler; +use crate::smtp_server::{SmtpHandler, Transaction}; use crate::utils::{AddressDomain, build_resolver, extract_address, log_eml}; use async_trait::async_trait; use hickory_resolver::TokioResolver; @@ -69,12 +69,10 @@ impl IncomingBeforeQueueHandler { #[async_trait] impl SmtpHandler for IncomingBeforeQueueHandler { - fn handle_mail(&self, _address: &str) -> Result<(), String> { - Ok(()) - } + type State = (); - async fn check_data(&self, envelope: &mut Envelope) -> Result<(), String> { - let message = match parse_mail(&envelope.data) { + async fn check_data(&self, transaction: &mut Transaction) -> Result<(), String> { + let message = match parse_mail(&transaction.envelope.data) { Ok(m) => m, Err(e) => return Err(format!("500 Failed to parse message: {}", e)), }; @@ -92,16 +90,21 @@ impl SmtpHandler for IncomingBeforeQueueHandler { log::debug!("Processing DATA message from {from_addr}"); - if !envelope.mail_from.eq_ignore_ascii_case(&from_addr) { + if !transaction + .envelope + .mail_from + .eq_ignore_ascii_case(&from_addr) + { // If the MAIL FROM doesn't match the From header, we do not reject the mail, // as this can be caused by e.g. SRS forwarding. // Instead, we reset the envelope address, so it is reinjected as // `MAIL FROM:<>` to prevent sending a bounce message. // - envelope.mail_from = String::new(); + transaction.envelope.mail_from = String::new(); } - envelope.rcpt_to = envelope + transaction.envelope.rcpt_to = transaction + .envelope .rcpt_to .iter() .filter(|s| { @@ -121,7 +124,7 @@ impl SmtpHandler for IncomingBeforeQueueHandler { // Allow encrypted or securejoin messages if mail_encrypted || is_securejoin(&message) { log::info!("Incoming: Filtering encrypted mail."); - return self.verify_origin(envelope, &from_addr).await; + return self.verify_origin(&transaction.envelope, &from_addr).await; } log::info!("Incoming: Filtering unencrypted mail."); @@ -132,26 +135,26 @@ impl SmtpHandler for IncomingBeforeQueueHandler { && from_addr.to_lowercase().starts_with("mailer-daemon@") && message.ctype.mimetype == "multipart/report" { - return self.verify_origin(envelope, &from_addr).await; + return self.verify_origin(&transaction.envelope, &from_addr).await; } - for recipient in &envelope.rcpt_to { + for recipient in &transaction.envelope.rcpt_to { if !self.config.is_cleartext_ok(recipient) { log::warn!("Rejected unencrypted mail from: {from_addr}"); return Err(ENCRYPTION_NEEDED_523.to_string()); } } - self.verify_origin(envelope, &from_addr).await + self.verify_origin(&transaction.envelope, &from_addr).await } - async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { + async fn reinject_mail(&self, transaction: &Transaction) -> Result<(), String> { log::debug!("Re-injecting the mail that passed checks"); let hostname = format!("[{}]", self.config.filtermail_host); crate::smtp_client::send( &self.config.postfix_host, self.config.postfix_reinject_port_incoming, - envelope, + &transaction.envelope, &hostname, None, self.dns_resolver.clone(), @@ -189,12 +192,14 @@ mod tests { config: Config, ) -> TestResult { let handler = IncomingBeforeQueueHandler::new(config, false)?; - let mut envelope = Envelope { - mail_from: address.to_string(), - origin_ip: "".to_string(), // Currently shouldn't be relevant. - data: eml.to_vec(), - rcpt_to: vec!["does.not.matter@example.org".to_string()], + let mut transaction = Transaction { + envelope: Envelope { + mail_from: address.to_string(), + data: eml.to_vec(), + rcpt_to: vec!["does.not.matter@example.org".to_string()], + }, + ..Default::default() }; - Ok(handler.check_data(&mut envelope).await?) + Ok(handler.check_data(&mut transaction).await?) } } diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index 165e8802..aa159242 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -33,6 +33,7 @@ pub(crate) mod message; pub(crate) mod openpgp; pub(crate) mod outbound; pub(crate) mod smtp_client; +mod smtp_responses; pub(crate) mod smtp_server; mod tls; mod transport; @@ -50,8 +51,6 @@ use std::process; use std::str::FromStr; use std::sync::Arc; -const ENCRYPTION_NEEDED_523: &str = "523 Encryption Needed: Invalid Unencrypted Mail"; - #[derive(Debug, Copy, Clone, PartialEq, Eq)] enum Mode { Outgoing, diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index cacf6e52..5fc96a42 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -1,11 +1,11 @@ //! Module for handling outgoing SMTP messages. -use crate::ENCRYPTION_NEEDED_523; use crate::config::Config; use crate::message::{check_encrypted, is_securejoin}; use crate::smtp_client::SmtpConnectionPool; -pub use crate::smtp_server::Envelope; -use crate::smtp_server::SmtpHandler; +use crate::smtp_responses::ENCRYPTION_NEEDED_523; +use crate::smtp_responses::OK_250; +use crate::smtp_server::{SmtpHandler, Transaction}; use crate::utils::{build_resolver, extract_address}; use async_trait::async_trait; use governor::clock::MonotonicClock; @@ -53,7 +53,9 @@ impl OutgoingBeforeQueueHandler { #[async_trait] impl SmtpHandler for OutgoingBeforeQueueHandler { - fn handle_mail(&self, address: &str) -> Result<(), String> { + type State = (); + + fn handle_mail_from(&self, address: &str) -> Result<(), String> { log::debug!("handle_MAIL from {address}"); let parts: Vec<&str> = address.split('@').collect(); @@ -79,8 +81,8 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { Ok(()) } - async fn check_data(&self, envelope: &mut Envelope) -> Result<(), String> { - let message = match parse_mail(&envelope.data) { + async fn check_data(&self, transaction: &mut Transaction) -> Result<(), String> { + let message = match parse_mail(&transaction.envelope.data) { Ok(m) => m, Err(e) => return Err(format!("500 Failed to parse message: {}", e)), }; @@ -97,7 +99,8 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { let from_addr = extract_address(&from_header) .ok_or(format!("500 Invalid FROM header: {from_header}"))?; - envelope.rcpt_to = envelope + transaction.envelope.rcpt_to = transaction + .envelope .rcpt_to .iter() .filter(|s| { @@ -113,12 +116,19 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { // MAIL FROM is our source of truth for outbound messages, // as this address is checked by postfix against the username before sending it // to filtermail. - log::debug!("Processing DATA message from {}", envelope.mail_from); + log::debug!( + "Processing DATA message from {}", + transaction.envelope.mail_from + ); - if !envelope.mail_from.eq_ignore_ascii_case(&from_addr) { + if !transaction + .envelope + .mail_from + .eq_ignore_ascii_case(&from_addr) + { return Err(format!( "500 Invalid FROM <{}> for <{}>", - from_addr, envelope.mail_from + from_addr, transaction.envelope.mail_from )); } @@ -131,8 +141,8 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { log::info!("Outgoing: Filtering unencrypted mail."); // Allow self-sent Autocrypt Setup Message - if envelope.rcpt_to.len() == 1 - && let Some(rcpt_to) = envelope.rcpt_to.first() + if transaction.envelope.rcpt_to.len() == 1 + && let Some(rcpt_to) = transaction.envelope.rcpt_to.first() && *rcpt_to == from_addr { let subject = message @@ -148,13 +158,13 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { Err(ENCRYPTION_NEEDED_523.to_string()) } - async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { + async fn reinject_mail(&self, transaction: &Transaction) -> Result<(), String> { log::debug!("Re-injecting the mail that passed checks"); let hostname = format!("[{}]", self.config.filtermail_host); crate::smtp_client::send( &self.config.postfix_host, self.config.postfix_reinject_port, - envelope, + &transaction.envelope, &hostname, None, self.dns_resolver.clone(), @@ -169,21 +179,27 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { Ok(()) } - async fn handle_data(&self, envelope: &mut Envelope) -> Result { + async fn handle_data_dot( + &self, + transaction: &mut Transaction, + ) -> Result { log::debug!("handle_DATA before-queue"); - self.check_data(envelope).await?; - if self.config.is_disabled(&envelope.mail_from) { - log::warn!("Dropping mail; Sender {} is disabled.", envelope.mail_from); - return Ok("250 OK".to_string()); + self.check_data(transaction).await?; + if self.config.is_disabled(&transaction.envelope.mail_from) { + log::warn!( + "Dropping mail; Sender {} is disabled.", + transaction.envelope.mail_from + ); + return Ok(OK_250.to_string()); } - if envelope.rcpt_to.is_empty() { + if transaction.envelope.rcpt_to.is_empty() { log::warn!("Dropping mail; All recipients disabled."); - return Ok("250 OK".to_string()); + return Ok(OK_250.to_string()); } - self.reinject_mail(envelope).await.map_err(|e| { + self.reinject_mail(transaction).await.map_err(|e| { log::warn!("Failed to reinject mail: {e}"); e })?; - Ok("250 OK".to_string()) + Ok(OK_250.to_string()) } } diff --git a/filtermail/src/smtp_responses.rs b/filtermail/src/smtp_responses.rs new file mode 100644 index 00000000..e12fba2a --- /dev/null +++ b/filtermail/src/smtp_responses.rs @@ -0,0 +1,7 @@ +pub const OK_250: &str = "250 OK"; +pub const OK_HTTPS_250: &str = "250 OK (HTTPS)"; +pub const OK_SMTP_250: &str = "250 OK (SMTP)"; + +pub const ENCRYPTION_NEEDED_523: &str = "523 Encryption Needed: Invalid Unencrypted Mail"; +pub const LOCAL_ERROR_451: &str = "451 Local error"; +pub const WORKER_BUSY_421: &str = "421 Worker for this destination is busy"; diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index f2ff8109..f1458caf 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -1,8 +1,10 @@ //! A simplified SMTP server implementation for internal communication. +use crate::smtp_responses::OK_250; use crate::utils::{extract_address, log_eml}; use async_trait::async_trait; use memchr::{Memchr, memmem}; +use std::fmt::Debug; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; @@ -12,9 +14,7 @@ use tokio::net::{TcpListener, TcpStream}; #[derive(Debug, Default, Clone)] pub struct Envelope { pub mail_from: String, - pub origin_ip: String, pub rcpt_to: Vec, - /// Mail data as transmitted over SMTP/LMTP. /// /// Described in . @@ -25,6 +25,16 @@ pub struct Envelope { pub data: Vec, } +/// Represent an ongoing SMTP transaction. +/// +/// Every new connection starts with an empty envelope and handler state. +/// A RSET command starts a new transaction, which clears the envelope and state. +#[derive(Debug, Default)] +pub struct Transaction { + pub envelope: Envelope, + pub state: S, +} + /// Checks if mail data is valid. fn is_valid_data(data: &[u8]) -> bool { // DATA must end with . @@ -59,19 +69,55 @@ fn is_valid_data(data: &[u8]) -> bool { /// Trait defining the SMTP handler interface. #[async_trait] pub trait SmtpHandler: Send + Sync { - /// Handles the MAIL FROM command. - fn handle_mail(&self, address: &str) -> Result<(), String>; + /// Transaction state type associated with this handler. + type State: Debug + Default + Send; /// Checks the DATA command before reinjection. /// /// Can optionally modify the envelope before reinjection. - async fn check_data(&self, envelope: &mut Envelope) -> Result<(), String>; + /// + /// Default implementation is no-op. + async fn check_data(&self, _transaction: &mut Transaction) -> Result<(), String> { + Ok(()) + } /// Reinjects the mail back to postfix. - async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String>; + /// + /// Default implementation is no-op. + async fn reinject_mail(&self, _transaction: &Transaction) -> Result<(), String> { + Ok(()) + } - /// Handles the DATA command. - async fn handle_data(&self, envelope: &mut Envelope) -> Result { + /// Handles the MAIL FROM command. + /// + /// Default implementation is no-op. + fn handle_mail_from(&self, _address: &str) -> Result<(), String> { + Ok(()) + } + + /// Handles the RCPT TO command. + /// + /// Default implementation is no-op. + fn handle_rcpt_to( + &self, + _address: &str, + _transaction: &mut Transaction, + ) -> Result<(), String> { + Ok(()) + } + + /// Handles the DATA command. Called after receiving DATA, before receiving actual data. + /// + /// Default implementation is no-op. + fn handle_data_start(&self, _transaction: &Transaction) -> Result<(), String> { + Ok(()) + } + + /// Handles the end of DATA command. Called after receiving the final dot. + async fn handle_data_dot( + &self, + transaction: &mut Transaction, + ) -> Result { log::debug!("handle_DATA before-queue"); // Check if the DATA is valid @@ -80,20 +126,20 @@ pub trait SmtpHandler: Send + Sync { // We are not going to normalize newlines // and escape the dots in the mail data. // If mail data turned out to be invalid, reject immediately. - if !is_valid_data(&envelope.data) { + if !is_valid_data(&transaction.envelope.data) { return Err("500 Invalid DATA".to_string()); } - self.check_data(envelope).await?; - if envelope.rcpt_to.is_empty() { + self.check_data(transaction).await?; + if transaction.envelope.rcpt_to.is_empty() { log::warn!("Dropping mail; All recipients disabled."); - return Ok("250 OK".to_string()); + return Ok(OK_250.to_string()); } - self.reinject_mail(envelope).await.map_err(|e| { + self.reinject_mail(transaction).await.map_err(|e| { log::warn!("Failed to reinject mail: {e}"); e })?; - Ok("250 OK".to_string()) + Ok("OK_250".to_string()) } } @@ -150,7 +196,7 @@ where writer.write_all(b"220 filtermail SMTP\r\n").await?; writer.flush().await?; - let mut envelope = Envelope::default(); + let mut transaction = Transaction::default(); 'connection: loop { line.clear(); @@ -181,28 +227,24 @@ where || cmd.to_uppercase().starts_with("LHLO") { writer - .write_all(b"250-filtermail\r\n250-XFORWARD ADDR\r\n250-8BITMIME\r\n250 OK\r\n") + .write_all(b"250-filtermail\r\n250-8BITMIME\r\n250 OK\r\n") .await?; writer.flush().await?; } else if cmd.to_uppercase().starts_with("MAIL FROM:<>") { // bounce message - envelope.mail_from = String::new(); - writer.write_all(b"250 OK\r\n").await?; + transaction.envelope.mail_from = String::new(); + writer.write_all(format!("{OK_250}\r\n").as_bytes()).await?; writer.flush().await?; } else if cmd.to_uppercase().starts_with("MAIL FROM:") { if let Some(from) = extract_address(cmd) { - match handler.handle_mail(&from) { - Ok(_) => { - envelope.mail_from = from; - writer.write_all(b"250 OK\r\n").await?; - writer.flush().await?; - } - Err(e) => { - writer.write_all(format!("{}\r\n", e).as_bytes()).await?; - writer.flush().await?; - break 'connection; - } + if let Err(e) = handler.handle_mail_from(&from) { + writer.write_all(format!("{}\r\n", e).as_bytes()).await?; + writer.flush().await?; + continue 'connection; } + transaction.envelope.mail_from = from; + writer.write_all(format!("{OK_250}\r\n").as_bytes()).await?; + writer.flush().await?; } else { log::warn!("Invalid MAIL FROM command. Can't extract address. Received: {cmd}"); writer @@ -212,11 +254,21 @@ where } } else if cmd.to_uppercase().starts_with("RCPT TO:") { if let Some(to) = extract_address(cmd) { - envelope.rcpt_to.push(to); - writer.write_all(b"250 OK\r\n").await?; + if let Err(e) = handler.handle_rcpt_to(&to, &mut transaction) { + writer.write_all(format!("{}\r\n", e).as_bytes()).await?; + writer.flush().await?; + continue 'connection; + } + transaction.envelope.rcpt_to.push(to); + writer.write_all(format!("{OK_250}\r\n").as_bytes()).await?; writer.flush().await?; } } else if cmd.to_uppercase().starts_with("DATA") { + if let Err(e) = handler.handle_data_start(&transaction) { + writer.write_all(format!("{}\r\n", e).as_bytes()).await?; + writer.flush().await?; + continue 'connection; + } writer .write_all(b"354 End data with .\r\n") .await?; @@ -255,14 +307,14 @@ where .write_all(b"552 Message exceeds maximum size\r\n") .await?; writer.flush().await?; - break 'connection; + continue 'connection; } } - envelope.data = data; + transaction.envelope.data = data; // Process the message - match handler.handle_data(&mut envelope).await { + match handler.handle_data_dot(&mut transaction).await { Ok(response) => { log::debug!("Sent: {response}"); writer @@ -277,29 +329,17 @@ where } } - envelope = Envelope::default(); - } else if cmd.to_uppercase().starts_with("XFORWARD") { - // https://www.postfix.org/XFORWARD_README.html - if let Some(addr_part) = cmd - .split_whitespace() - .find(|part| part.to_uppercase().starts_with("ADDR=")) - && let Some(ip) = addr_part.strip_prefix("ADDR=") - { - let ip = ip.to_lowercase(); - envelope.origin_ip = ip.strip_prefix("ipv6:").unwrap_or(&ip).to_string(); - writer.write_all(b"250 OK\r\n").await?; - writer.flush().await?; - } + transaction = Transaction::default(); } else if cmd.to_uppercase().starts_with("QUIT") { writer.write_all(b"221 OK\r\n").await?; writer.flush().await?; break 'connection; } else if cmd.to_uppercase().starts_with("RSET") { - envelope = Envelope::default(); - writer.write_all(b"250 OK\r\n").await?; + transaction = Transaction::default(); + writer.write_all(format!("{OK_250}\r\n").as_bytes()).await?; writer.flush().await?; } else if cmd.to_uppercase().starts_with("NOOP") { - writer.write_all(b"250 OK\r\n").await?; + writer.write_all(format!("{OK_250}\r\n").as_bytes()).await?; writer.flush().await?; } else { writer.write_all(b"500 Command not recognized\r\n").await?; diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs index 06fd4d62..0d02a46b 100644 --- a/filtermail/src/transport.rs +++ b/filtermail/src/transport.rs @@ -1,375 +1,112 @@ +mod https_client; +mod worker; + use crate::config::Config; -use crate::smtp_client::{SmtpConnectionPool, TlsConfig}; -use crate::smtp_server::{Envelope, SmtpHandler}; -use crate::tls; -use crate::utils::{AddressDomain, build_resolver}; +use crate::smtp_responses::{LOCAL_ERROR_451, WORKER_BUSY_421}; +use crate::smtp_server::{SmtpHandler, Transaction}; +use crate::utils::AddressDomain; use async_trait::async_trait; -use hickory_resolver::{TokioResolver, proto::rr::RData}; -use http_body_util::BodyExt; -use hyper::body::Bytes; -use hyper_rustls::HttpsConnector; -use hyper_util::client::legacy::connect::HttpConnector; use std::collections::BTreeMap; use std::str::FromStr; -use std::sync::Arc; -use std::time::Duration; -use tokio::task::{JoinHandle, JoinSet}; -use tokio_rustls::rustls; +use tokio::sync::mpsc::OwnedPermit; +use tokio::task::JoinSet; +use worker::{WorkerMessage, WorkerPool}; pub const HEADER_MAIL_FROM: &str = "X-MAIL-FROM"; pub const HEADER_RCPT_TO: &str = "X-MAIL-TO"; -/// Cheaply clonable HTTPS client. -/// -/// Holds regular secure variant and relaxed - without certificate verification. -/// -/// Connection pool handled internally by [`hyper_util::client::legacy::Client`]. -#[derive(Clone)] -struct HttpsClient { - pub secure: hyper_util::client::legacy::Client< - HttpsConnector, - http_body_util::Full, - >, - pub relaxed: hyper_util::client::legacy::Client< - HttpsConnector, - http_body_util::Full, - >, -} - -impl HttpsClient { - /// Creates a new `[HttpsClient]`. - pub fn new( - tls_resumption_store: Arc, - ) -> Result { - let tls_client_config = tls::configure_rustls(tls_resumption_store.clone(), false)?; - let https_connector = hyper_rustls::HttpsConnectorBuilder::new() - .with_tls_config(tls_client_config) - .https_only() - .enable_http1() - .enable_http2() - .build(); - let https_client = - hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) - .build(https_connector); - - let tls_client_config_relaxed = tls::configure_rustls(tls_resumption_store, true)?; - let https_connector_relaxed = hyper_rustls::HttpsConnectorBuilder::new() - .with_tls_config(tls_client_config_relaxed) - .https_only() - .enable_http1() - .enable_http2() - .build(); - let https_client_relaxed = - hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) - .build(https_connector_relaxed); - - Ok(Self { - secure: https_client, - relaxed: https_client_relaxed, - }) - } -} - pub struct TransportHandler { - config: Config, - dns_resolver: Arc, - tls_resumption_store: Arc, - smtp_connection_pool: Arc, - https_client: HttpsClient, - mxdeliv_unsupported_hosts: Arc>, - monitor_handle: JoinHandle<()>, + workers: WorkerPool, } impl TransportHandler { + /// Creates a new [`TransportHandler`]. pub fn new(config: Config) -> Result { - let dns_resolver = Arc::new(build_resolver()?); - let tls_resumption_store = Arc::new(rustls::client::ClientSessionMemoryCache::new(256)); - let https_client = HttpsClient::new(tls_resumption_store.clone())?; + let workers = WorkerPool::new(config)?; - let mxdeliv_cache = Arc::new(retainer::Cache::new()); - let mxdeliv_cache_clone = mxdeliv_cache.clone(); - - let monitor_handle = tokio::spawn(async move { - mxdeliv_cache_clone - .monitor(4, 0.25, Duration::from_secs(10)) - .await - }); - - Ok(Self { - config, - dns_resolver, - tls_resumption_store, - smtp_connection_pool: SmtpConnectionPool::new(), - https_client, - mxdeliv_unsupported_hosts: mxdeliv_cache, - monitor_handle, - }) + Ok(Self { workers }) } - /// Handles a single email transaction for a single recipient domain. - #[expect(clippy::too_many_arguments)] - async fn handle_single_domain( - tls_resumption_store: Arc, - smtp_connection_pool: Arc, - mxdeliv_unsupported_hosts: Arc>, - https_client: HttpsClient, - dns_resolver: Arc, - domain: AddressDomain, - envelope: Envelope, - client_hostname: String, - ) -> Result { - let mut allow_invalid_cert = false; - let mut skip_tls = false; // only respected by smtp channel - - let mx_hosts = match domain { - // no-DNS setup; assume the ip from email address is the destination. - AddressDomain::Literal(ip) => { - // We allow self-signed certs on IP-based relays. - allow_invalid_cert = true; - vec![(0, ip)] - } - AddressDomain::Name(mx_domain) => { - if mx_domain.eq_ignore_ascii_case("nauta.cu") { - // Special case; We don't want to defederate nauta.cu, - // which doesn't support STARTTLS at all. - skip_tls = true; - } else if mx_domain.starts_with('_') { - // We use domains starting with `_` for test deployments. - // (You can't request a non-wildcard cert for such domain) - allow_invalid_cert = true; - } - let query = format!("{mx_domain}."); - - match dns_resolver.mx_lookup(query).await { - Ok(mx_records) => { - let mut hosts: Vec<(u16, String)> = Vec::new(); - for mx_record in mx_records.answers() { - let mx = match mx_record.data { - RData::MX(ref mx) => mx, - _ => continue, - }; - - // Null MX / RFC7505 - if mx.exchange.is_root() { - // From RFC7505 section 3: - // > A domain that advertises a null MX MUST NOT - // > advertise any other MX RR. - // We assume this is the only record and exit early. - return Err( - "556 5.1.10 Permanent failure: Recipient address has null MX" - .to_string(), - ); - } - - let host = mx.exchange.to_string().trim_end_matches('.').to_string(); - hosts.push((mx.preference, host)) - } - hosts.sort(); - hosts - } - Err(e) => { - if e.is_no_records_found() { - // "implicit MX" as described by section 5.1 of RFC5321 - // https://datatracker.ietf.org/doc/html/rfc5321#section-5.1 - log::debug!("No MX record found, using implicit MX: {mx_domain}"); - vec![(0, mx_domain)] - } else if e.is_nx_domain() { - return Err(format!("512 Domain {mx_domain} does not exist")); - } else { - return Err(format!("421 DNS resolution failed for {mx_domain}")); - } - } - } - } - }; - - let tls_config = match skip_tls { - true => None, - false => Some(TlsConfig { - allow_invalid_cert, - session_cache: tls_resumption_store, - }), - }; - - let mut last_error = None; - - // we try sequentially in order of MX preference, - // but the IPv4 and IPv6 connections (after `smtp_client::send` resolves mx hostname) - // happens in parallel. - 'try_relay: for (_, mx_host) in mx_hosts { - let skip_mxdeliv = mxdeliv_unsupported_hosts - .get(&mx_host) - .await - .map(|guard| *guard.value()) - .unwrap_or(false); - - // HTTPS channel - if skip_mxdeliv { - log::debug!("Skipping HTTP delivery to host that failed recently: {mx_host}"); - } else { - match Self::https_delivery( - https_client.clone(), - mx_host.clone(), - &envelope, - allow_invalid_cert, - ) - .await - { - Ok(_) => { - return Ok("250 Ok (HTTPS)".to_string()); - } - Err(e) => { - log::debug!("HTTPS delivery to {mx_host} failed: {e}"); - } - } - } - - // SMTP channel (fallback) - match crate::smtp_client::send( - &mx_host, - 25, - &envelope, - &client_hostname, - tls_config.clone(), - dns_resolver.clone(), - smtp_connection_pool.clone(), - ) - .await - { - Ok(_) => { - // Switches this host to SMTP for 30 minutes. - // Note: this MUST happen only after a successful SMTP delivery, - // or otherwise any http error will lock us out of any way to - // deliver to a relay with a blocked port 25 for 30 minutes. - mxdeliv_unsupported_hosts - .insert(mx_host.clone(), true, Duration::from_mins(30)) - .await; - return Ok("250 Ok (SMTP)".to_string()); - } - Err(error) => { - match &error { - // We only want to try other MX hosts if we encounter a problem - // related to connection. - // (So we don't spam other servers if the message is actually rejected.) - crate::error::Error::Io(_) - | crate::error::Error::ConnectionFailed(_) - | crate::error::Error::Tls(_) => { - // Make sure we quickly retry HTTP if SMTP failed to connect - mxdeliv_unsupported_hosts.remove(&mx_host).await; - log::warn!( - "Connection error relaying to mail server {mx_host}: {error}" - ); - last_error = Some((error.smtp_response(), mx_host.clone())); - continue 'try_relay; - } - crate::error::Error::MailSend { .. } => { - log::warn!("Message rejected by mail server {mx_host}: {error}"); - return Err(error.smtp_response()); - } - _ => { - log::warn!( - "Unexpected error while delivering to mail server {mx_host}: {error}" - ); - return Err(format!( - "{} (while attempting delivery to {mx_host})", - error.smtp_response() - )); - } - } - } - } - } - - let (error, mx_host) = last_error.unwrap_or(("?".to_string(), "?".to_string())); - Err(format!( - "421 Failed to connect to any mail server; last attempt to {mx_host}: {error}" - )) - } - - /// Performs mail delivery to `mx_host` over HTTPS. + /// Same as [`Self::new`], but lets you set worker queue size. /// - /// Times out after 60s. - async fn https_delivery( - https_client: HttpsClient, - mx_host: String, - envelope: &Envelope, - allow_invalid_cert: bool, - ) -> Result<(), crate::error::Error> { - let request: hyper::Request> = { - let mut builder = hyper::Request::builder() - .method(hyper::Method::POST) - .uri(format!("https://{mx_host}/mxdeliv")); + /// Only used for tests. + #[cfg(test)] + pub fn with_queue_size(config: Config, queue_size: usize) -> Result { + let workers = WorkerPool::with_queue_size(config, queue_size)?; - if !envelope.mail_from.is_empty() { - builder = builder.header(HEADER_MAIL_FROM, &envelope.mail_from); - } - - for rcpt_to in &envelope.rcpt_to { - builder = builder.header(HEADER_RCPT_TO, rcpt_to); - } - - builder.body(http_body_util::Full::from(envelope.data.clone()))? - }; - - let client = if allow_invalid_cert { - https_client.relaxed - } else { - https_client.secure - }; - - let response = tokio::time::timeout(Duration::from_secs(60), client.request(request)) - .await - .map_err(|_| crate::error::Error::MailSend { - context: "HTTPS delivery".to_string(), - raw_smtp_answer: "[timeout]".to_string(), - host: mx_host.clone(), - })??; - if response.status().is_success() { - Ok(()) - } else { - let response_body = response.collect().await?.to_bytes(); - Err(crate::error::Error::MailSend { - context: "HTTPS delivery".to_string(), - raw_smtp_answer: String::from_utf8_lossy(&response_body).into(), - host: mx_host, - }) - } + Ok(Self { workers }) } } -impl Drop for TransportHandler { - fn drop(&mut self) { - self.monitor_handle.abort(); - } +#[derive(Debug, Default)] +pub struct TransactionState { + permits: BTreeMap>, } #[async_trait] impl SmtpHandler for TransportHandler { - /// NO-OP - fn handle_mail(&self, _: &str) -> Result<(), String> { + type State = TransactionState; + + fn handle_rcpt_to( + &self, + address: &str, + transaction: &mut Transaction, + ) -> Result<(), String> { + let domain = AddressDomain::from_str(address).map_err(|e| e.smtp_response())?; + + if transaction.state.permits.contains_key(&domain) { + // We already acquired a permit for this domain + return Ok(()); + } + + log::trace!( + "Trying to acquire a permit for {} worker...", + domain.as_ref() + ); + if let Some(permit) = self.workers.get_permit(&domain) { + transaction.state.permits.insert(domain, permit); + } + Ok(()) } - /// NO-OP - async fn check_data(&self, _: &mut Envelope) -> Result<(), String> { - Ok(()) - } + fn handle_data_start(&self, transaction: &Transaction) -> Result<(), String> { + // We want to prevent needlessly sending data from postfix to filtermail, + // so we fail here if we didn't get any permit. + // + // Examplary scenario: + // Consider destinations A and B, where A is unavailable. + // We are sending a message to a group of 1@A, 2@A, 1@B, 2@B. + // After handle_rcpt_to on every recipient, we end up with a permit for domain B (A fails). + // handle_data_start passes and mail data is transmitted to filtermail. + // Delivery to B is performed; 1@B and 2@B receive message and a message to 1@A and 2@A + // is deferred. + // After some time the message is retried, now we only try to acquire permit for A, + // but fail -> empty `transaction.state.permits` + // handle_data_start fails and mail data is not sent to filtermail. + // This greatly reduces RAM usage, as unavailable destination can cause large numbers of + // deferred mails to be constantly retried. + + if transaction.state.permits.is_empty() { + return Err(WORKER_BUSY_421.to_string()); + } - /// NO-OP - async fn reinject_mail(&self, _: &Envelope) -> Result<(), String> { Ok(()) } /// Handles the DATA command and returns LMTP responses as single string. /// /// Never returns an error, as LMTP response is composite. - async fn handle_data(&self, envelope: &mut Envelope) -> Result { + async fn handle_data_dot( + &self, + transaction: &mut Transaction, + ) -> Result { let mut domain_rcpts_map = BTreeMap::new(); - for rcpt in &envelope.rcpt_to { + for rcpt in &transaction.envelope.rcpt_to { let domain = AddressDomain::from_str(rcpt) // Currently we cancel all transactions if any recipient address is invalid. - .map_err(|e| e.lmtp_response(envelope.rcpt_to.len()))?; + .map_err(|e| e.lmtp_response(transaction.envelope.rcpt_to.len()))?; domain_rcpts_map .entry(domain) .or_insert_with(Vec::new) @@ -382,40 +119,50 @@ impl SmtpHandler for TransportHandler { for (rcpt_domain, rcpts) in &domain_rcpts_map { let domain_envelope = { - let mut envelope = envelope.clone(); + let mut envelope = transaction.envelope.clone(); envelope.rcpt_to = rcpts.clone(); envelope }; - let task_id = transactions - .spawn(Self::handle_single_domain( - self.tls_resumption_store.clone(), - self.smtp_connection_pool.clone(), - self.mxdeliv_unsupported_hosts.clone(), - self.https_client.clone(), - self.dns_resolver.clone(), - rcpt_domain.clone(), - domain_envelope, - self.config.mail_domain.clone(), - )) - .id(); - task_id_domain_map.insert(task_id, rcpt_domain); + let receiver_task_id = + if let Some(permit) = transaction.state.permits.remove(rcpt_domain) { + let (message, receiver) = WorkerMessage::new(domain_envelope); + permit.send(message); + // todo: receiver timeout? + transactions.spawn(receiver).id() + } else { + transactions + .spawn(async move { Ok(Err(WORKER_BUSY_421.to_string())) }) + .id() + }; + task_id_domain_map.insert(receiver_task_id, rcpt_domain); } let mut rcpt_response_map = BTreeMap::new(); while let Some(result) = transactions.join_next_with_id().await { - let domain_response = match result { - Ok((id, Ok(resp))) | Ok((id, Err(resp))) => { - task_id_domain_map.remove(&id).map(|domain| (domain, resp)) + let domain = match &result { + Ok((id, _)) => task_id_domain_map.remove(id), + Err(e) => task_id_domain_map.remove(&e.id()), + }; + + let smtp_response = match result { + Ok((_, Ok(Ok(resp)))) | Ok((_, Ok(Err(resp)))) => resp, + Ok((_, Err(e))) => { + log::error!( + "Worker task failed while delivering to {}: {e}", + domain.map(AsRef::as_ref).unwrap_or("") + ); + LOCAL_ERROR_451.to_string() } Err(e) => { - log::error!("Failed to join task: {e}"); - task_id_domain_map - .remove(&e.id()) - .map(|domain| (domain, "451 Local error".to_string())) + log::error!( + "Failed to join task while delivering to {}: {e}", + domain.map(AsRef::as_ref).unwrap_or("") + ); + LOCAL_ERROR_451.to_string() } }; - if let Some((domain, smtp_response)) = domain_response + if let Some(domain) = domain && let Some(rcpts) = domain_rcpts_map.get(domain) { for rcpt in rcpts { @@ -425,16 +172,95 @@ impl SmtpHandler for TransportHandler { } // compose lmtp response... - let ordered_responses: Vec = envelope + let ordered_responses: Vec = transaction + .envelope .rcpt_to .iter() .map(|rcpt| { rcpt_response_map .remove(rcpt) - .unwrap_or_else(|| "451 Local error".to_string()) + .unwrap_or_else(|| LOCAL_ERROR_451.to_string()) }) .collect(); Ok(ordered_responses.join("\r\n")) } } + +#[cfg(test)] +mod tests { + use super::*; + use rstest::{fixture, rstest}; + use testresult::TestResult; + + #[fixture] + fn addrs1() -> Vec { + let mut vec = Vec::new(); + for idx in 0..5 { + vec.push(format!("{idx}@one.example.org")) + } + vec + } + + #[fixture] + fn addrs2() -> Vec { + let mut vec = Vec::new(); + for idx in 0..5 { + vec.push(format!("{idx}@two.example.org")) + } + vec + } + + #[rstest] + #[tokio::test] + async fn test_rcpt_to_and_start_data(addrs1: Vec, addrs2: Vec) -> TestResult { + let transport_handler = TransportHandler::with_queue_size(Config::default(), 1)?; + let domain1 = AddressDomain::from_str(addrs1.first().unwrap())?; + let domain2 = AddressDomain::from_str(addrs2.first().unwrap())?; + + { + let mut trans_1 = Transaction::default(); + let mut trans_2 = Transaction::default(); + let mut trans_3 = Transaction::default(); + + transport_handler.handle_rcpt_to(addrs1.first().unwrap(), &mut trans_1)?; + assert!(trans_1.state.permits.contains_key(&domain1)); + + // Within one transaction, we only use one worker permit, so queue_size=1 is enough. + transport_handler.handle_rcpt_to(addrs1.get(1).unwrap(), &mut trans_1)?; + assert!(trans_1.state.permits.contains_key(&domain1)); + + // However, a second transaction with the same domain won't get a permit. + transport_handler.handle_rcpt_to(addrs1.get(2).unwrap(), &mut trans_2)?; + assert!(!trans_2.state.permits.contains_key(&domain1)); + + // Different domain will work though, as it uses a separate worker, with its own queue. + transport_handler.handle_rcpt_to(addrs2.first().unwrap(), &mut trans_2)?; + assert!(trans_2.state.permits.contains_key(&domain2)); + + // Third transaction won't get any permits. + transport_handler.handle_rcpt_to(addrs1.get(3).unwrap(), &mut trans_3)?; + transport_handler.handle_rcpt_to(addrs2.get(2).unwrap(), &mut trans_3)?; + assert!(!trans_3.state.permits.contains_key(&domain1)); + assert!(!trans_3.state.permits.contains_key(&domain2)); + + // all permits granted -> accept DATA command + assert_eq!(transport_handler.handle_data_start(&trans_1), Ok(())); + + // some permits granted -> accept DATA command + assert_eq!(transport_handler.handle_data_start(&trans_2), Ok(())); + + // no permits granted -> reject + assert!(transport_handler.handle_data_start(&trans_3).is_err()); + } + + // Transactions (and owned by them permits) going out of scope frees the queues. + let mut trans_4 = Transaction::default(); + transport_handler.handle_rcpt_to(addrs1.first().unwrap(), &mut trans_4)?; + transport_handler.handle_rcpt_to(addrs2.first().unwrap(), &mut trans_4)?; + assert!(trans_4.state.permits.contains_key(&domain1)); + assert!(trans_4.state.permits.contains_key(&domain2)); + + Ok(()) + } +} diff --git a/filtermail/src/transport/https_client.rs b/filtermail/src/transport/https_client.rs new file mode 100644 index 00000000..eb6e10be --- /dev/null +++ b/filtermail/src/transport/https_client.rs @@ -0,0 +1,57 @@ +use crate::tls; +use hyper::body::Bytes; +use hyper_rustls::HttpsConnector; +use hyper_util::client::legacy::connect::HttpConnector; +use std::sync::Arc; +use tokio_rustls::rustls; + +/// Cheaply clonable HTTPS client. +/// +/// Holds regular secure variant and relaxed - without certificate verification. +/// +/// Connection pool handled internally by [`hyper_util::client::legacy::Client`]. +#[derive(Clone)] +pub(crate) struct HttpsClient { + pub secure: hyper_util::client::legacy::Client< + HttpsConnector, + http_body_util::Full, + >, + pub relaxed: hyper_util::client::legacy::Client< + HttpsConnector, + http_body_util::Full, + >, +} + +impl HttpsClient { + /// Creates a new `[HttpsClient]`. + pub fn new( + tls_resumption_store: Arc, + ) -> Result { + let tls_client_config = tls::configure_rustls(tls_resumption_store.clone(), false)?; + let https_connector = hyper_rustls::HttpsConnectorBuilder::new() + .with_tls_config(tls_client_config) + .https_only() + .enable_http1() + .enable_http2() + .build(); + let https_client = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build(https_connector); + + let tls_client_config_relaxed = tls::configure_rustls(tls_resumption_store, true)?; + let https_connector_relaxed = hyper_rustls::HttpsConnectorBuilder::new() + .with_tls_config(tls_client_config_relaxed) + .https_only() + .enable_http1() + .enable_http2() + .build(); + let https_client_relaxed = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build(https_connector_relaxed); + + Ok(Self { + secure: https_client, + relaxed: https_client_relaxed, + }) + } +} diff --git a/filtermail/src/transport/worker.rs b/filtermail/src/transport/worker.rs new file mode 100644 index 00000000..64a199a3 --- /dev/null +++ b/filtermail/src/transport/worker.rs @@ -0,0 +1,447 @@ +use crate::config::Config; +use crate::smtp_client::{SmtpConnectionPool, TlsConfig}; +use crate::smtp_responses::{OK_HTTPS_250, OK_SMTP_250}; +use crate::smtp_server::Envelope; +use crate::transport::{HEADER_MAIL_FROM, HEADER_RCPT_TO, https_client::HttpsClient}; +use crate::utils::{AddressDomain, build_resolver}; +use hickory_resolver::TokioResolver; +use hickory_resolver::proto::rr::RData; +use http_body_util::BodyExt; +use hyper::body::Bytes; +use parking_lot::RwLock; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc::OwnedPermit; +use tokio::sync::{mpsc, oneshot}; +use tokio::task; +use tokio::task::JoinHandle; +use tokio_rustls::rustls; + +/// Message queue size per [`Worker`]. +/// +/// If a queue to a single destination reaches this limit, +/// all new messages will be immediately deferred. +const PER_DESTINATION_QUEUE_SIZE: usize = 30; + +type SMTPResponse = Result; + +pub struct WorkerPool { + inner: RwLock>>, + client_hostname: String, + smtp_connection_pool: Arc, + mxdeliv_unsupported_hosts: Arc>, + monitor_handle: JoinHandle<()>, + dns_resolver: Arc, + queue_size: usize, +} + +impl WorkerPool { + pub fn new(config: Config) -> Result { + let dns_resolver = Arc::new(build_resolver()?); + + let mxdeliv_cache = Arc::new(retainer::Cache::new()); + let mxdeliv_cache_clone = mxdeliv_cache.clone(); + + let monitor_handle = tokio::spawn(async move { + mxdeliv_cache_clone + .monitor(4, 0.25, Duration::from_secs(10)) + .await + }); + + Ok(Self { + inner: Default::default(), + client_hostname: config.mail_domain, + dns_resolver, + smtp_connection_pool: SmtpConnectionPool::new(), + mxdeliv_unsupported_hosts: mxdeliv_cache, + monitor_handle, + queue_size: PER_DESTINATION_QUEUE_SIZE, + }) + } + + /// Same as [`Self::new`], but lets you set the size of the queue. + /// + /// Used only for tests. + #[cfg(test)] + pub fn with_queue_size(config: Config, queue_size: usize) -> Result { + let mut this = Self::new(config)?; + this.queue_size = queue_size; + Ok(this) + } + + fn get_or_create_worker(&self, destination: &AddressDomain) -> Arc { + // NOTE: these locks are blocking, but critical section here is quite small and + // shouldn't cause issues in async code. + // NOTE: read() returns a guard that is dropped before the match statement. + // This must be ensured or else, the write() line would cause a deadlock. + let worker = { + let mut worker = { + let map = self.inner.read(); + map.get(destination).cloned() + }; + // Remove (and re-create) worker if it finished/crashed. + // In reality, this should never happen. + if let Some(w) = &worker + && w.handle.is_finished() + { + log::error!( + "Worker for destination {} crashed! Restarting...", + destination.as_ref() + ); + worker = None; + { + let mut map = self.inner.write(); + map.remove(destination); + } + }; + worker + }; + + match worker { + Some(worker) => worker, + None => { + // Worker for this destination wasn't spawned yet. + let (tx, rx) = mpsc::channel(self.queue_size); + let handle = tokio::spawn(Worker::run( + destination.clone(), + rx, + self.client_hostname.clone(), + self.smtp_connection_pool.clone(), + self.mxdeliv_unsupported_hosts.clone(), + self.dns_resolver.clone(), + )); + log::trace!("Worker {} spawned", handle.id()); + let worker = Arc::new(Worker { tx, handle }); + + self.inner + .write() + .insert(destination.clone(), worker.clone()); + worker + } + } + } + + /// Tries to get an [`OwnedPermit`] to the worker for specified destination. + /// + /// Returns [`None`] if the worker's queue is full. + pub fn get_permit(&self, destination: &AddressDomain) -> Option> { + let worker = self.get_or_create_worker(destination); + worker.tx.clone().try_reserve_owned().ok() + } +} + +impl Drop for WorkerPool { + fn drop(&mut self) { + self.monitor_handle.abort(); + } +} + +#[derive(Debug)] +pub struct Worker { + pub tx: mpsc::Sender, + handle: JoinHandle>, +} + +impl Drop for Worker { + fn drop(&mut self) { + self.handle.abort(); + } +} + +impl Worker { + pub async fn run( + destination: AddressDomain, + mut rx: mpsc::Receiver, + client_hostname: String, + smtp_connection_pool: Arc, + mxdeliv_unsupported_hosts: Arc>, + dns_resolver: Arc, + ) -> Result<(), crate::error::Error> { + let worker_id = task::try_id() + .map(|id| id.to_string()) + .unwrap_or("?".to_string()); + + log::info!( + "Starting worker {worker_id} for destination {}", + destination.as_ref() + ); + + let tls_resumption_store = Arc::new(rustls::client::ClientSessionMemoryCache::new(256)); + let https_client = HttpsClient::new(tls_resumption_store.clone())?; + + while let Some(message) = rx.recv().await { + log::trace!( + "Worker {worker_id} received a message from {}", + message.envelope.mail_from + ); + let result = Self::handle_single_domain( + tls_resumption_store.clone(), + smtp_connection_pool.clone(), + mxdeliv_unsupported_hosts.clone(), + https_client.clone(), + dns_resolver.clone(), + destination.clone(), + message.envelope, + client_hostname.clone(), + ) + .await; + if message.response_tx.send(result).is_err() { + log::error!( + "Worker {worker_id} ({}) failed to send response to transport handler.", + destination.as_ref() + ); + }; + } + + Ok(()) + } + + /// Handles a single email transaction for a single recipient domain. + #[expect(clippy::too_many_arguments)] + async fn handle_single_domain( + tls_resumption_store: Arc, + smtp_connection_pool: Arc, + mxdeliv_unsupported_hosts: Arc>, + https_client: HttpsClient, + dns_resolver: Arc, + domain: AddressDomain, + envelope: Envelope, + client_hostname: String, + ) -> Result { + let mut allow_invalid_cert = false; + let mut skip_tls = false; // only respected by smtp channel + + let mx_hosts = match domain { + // no-DNS setup; assume the ip from email address is the destination. + AddressDomain::Literal(ip) => { + // We allow self-signed certs on IP-based relays. + allow_invalid_cert = true; + vec![(0, ip)] + } + AddressDomain::Name(mx_domain) => { + if mx_domain.eq_ignore_ascii_case("nauta.cu") { + // Special case; We don't want to defederate nauta.cu, + // which doesn't support STARTTLS at all. + skip_tls = true; + } else if mx_domain.starts_with('_') { + // We use domains starting with `_` for test deployments. + // (You can't request a non-wildcard cert for such domain) + allow_invalid_cert = true; + } + let query = format!("{mx_domain}."); + + match dns_resolver.mx_lookup(query).await { + Ok(mx_records) => { + let mut hosts: Vec<(u16, String)> = Vec::new(); + for mx_record in mx_records.answers() { + let mx = match mx_record.data { + RData::MX(ref mx) => mx, + _ => continue, + }; + + // Null MX / RFC7505 + if mx.exchange.is_root() { + // From RFC7505 section 3: + // > A domain that advertises a null MX MUST NOT + // > advertise any other MX RR. + // We assume this is the only record and exit early. + return Err( + "556 5.1.10 Permanent failure: Recipient address has null MX" + .to_string(), + ); + } + + let host = mx.exchange.to_string().trim_end_matches('.').to_string(); + hosts.push((mx.preference, host)) + } + hosts.sort(); + hosts + } + Err(e) => { + if e.is_no_records_found() { + // "implicit MX" as described by section 5.1 of RFC5321 + // https://datatracker.ietf.org/doc/html/rfc5321#section-5.1 + log::debug!("No MX record found, using implicit MX: {mx_domain}"); + vec![(0, mx_domain)] + } else if e.is_nx_domain() { + return Err(format!("512 Domain {mx_domain} does not exist")); + } else { + return Err(format!("421 DNS resolution failed for {mx_domain}")); + } + } + } + } + }; + + let tls_config = match skip_tls { + true => None, + false => Some(TlsConfig { + allow_invalid_cert, + session_cache: tls_resumption_store, + }), + }; + + let mut last_error = None; + + // we try sequentially in order of MX preference, + // but the IPv4 and IPv6 connections (after `smtp_client::send` resolves mx hostname) + // happens in parallel. + 'try_relay: for (_, mx_host) in mx_hosts { + let skip_mxdeliv = mxdeliv_unsupported_hosts + .get(&mx_host) + .await + .map(|guard| *guard.value()) + .is_some(); + + // HTTPS channel + if skip_mxdeliv { + log::debug!("Skipping HTTP delivery to host that failed recently: {mx_host}"); + } else { + match Self::https_delivery( + https_client.clone(), + mx_host.clone(), + &envelope, + allow_invalid_cert, + ) + .await + { + Ok(_) => { + return Ok(OK_HTTPS_250.to_string()); + } + Err(e) => { + log::debug!("HTTPS delivery to {mx_host} failed: {e}"); + } + } + } + + // SMTP channel (fallback) + match crate::smtp_client::send( + &mx_host, + 25, + &envelope, + &client_hostname, + tls_config.clone(), + dns_resolver.clone(), + smtp_connection_pool.clone(), + ) + .await + { + Ok(_) => { + // Switches this host to SMTP for 30 minutes. + // Note: this MUST happen only after a successful SMTP delivery, + // or otherwise any http error will lock us out of any way to + // deliver to a relay with a blocked port 25 for 30 minutes. + mxdeliv_unsupported_hosts + .insert(mx_host.clone(), (), Duration::from_mins(30)) + .await; + return Ok(OK_SMTP_250.to_string()); + } + Err(error) => { + match &error { + // We only want to try other MX hosts if we encounter a problem + // related to connection. + // (So we don't spam other servers if the message is actually rejected.) + crate::error::Error::Io(_) + | crate::error::Error::ConnectionFailed(_) + | crate::error::Error::Tls(_) => { + // Make sure we quickly retry HTTP if SMTP failed to connect + mxdeliv_unsupported_hosts.remove(&mx_host).await; + log::warn!( + "Connection error relaying to mail server {mx_host}: {error}" + ); + last_error = Some((error.smtp_response(), mx_host.clone())); + continue 'try_relay; + } + crate::error::Error::MailSend { .. } => { + log::warn!("Message rejected by mail server {mx_host}: {error}"); + return Err(error.smtp_response()); + } + _ => { + log::warn!( + "Unexpected error while delivering to mail server {mx_host}: {error}" + ); + return Err(format!( + "{} (while attempting delivery to {mx_host})", + error.smtp_response() + )); + } + } + } + } + } + + let (error, mx_host) = last_error.unwrap_or(("?".to_string(), "?".to_string())); + Err(format!( + "421 Failed to connect to any mail server; last attempt to {mx_host}: {error}" + )) + } + + /// Performs mail delivery to `mx_host` over HTTPS. + /// + /// Times out after 60s. + async fn https_delivery( + https_client: HttpsClient, + mx_host: String, + envelope: &Envelope, + allow_invalid_cert: bool, + ) -> Result<(), crate::error::Error> { + let request: hyper::Request> = { + let mut builder = hyper::Request::builder() + .method(hyper::Method::POST) + .uri(format!("https://{mx_host}/mxdeliv")); + + if !envelope.mail_from.is_empty() { + builder = builder.header(HEADER_MAIL_FROM, &envelope.mail_from); + } + + for rcpt_to in &envelope.rcpt_to { + builder = builder.header(HEADER_RCPT_TO, rcpt_to); + } + + builder.body(http_body_util::Full::from(envelope.data.clone()))? + }; + + let client = if allow_invalid_cert { + https_client.relaxed + } else { + https_client.secure + }; + + let response = tokio::time::timeout(Duration::from_secs(60), client.request(request)) + .await + .map_err(|_| crate::error::Error::MailSend { + context: "HTTPS delivery".to_string(), + raw_smtp_answer: "[timeout]".to_string(), + host: mx_host.clone(), + })??; + if response.status().is_success() { + Ok(()) + } else { + let response_body = response.collect().await?.to_bytes(); + Err(crate::error::Error::MailSend { + context: "HTTPS delivery".to_string(), + raw_smtp_answer: String::from_utf8_lossy(&response_body).into(), + host: mx_host, + }) + } + } +} + +pub struct WorkerMessage { + pub envelope: Envelope, + pub response_tx: oneshot::Sender, +} + +impl WorkerMessage { + pub fn new(envelope: Envelope) -> (Self, oneshot::Receiver) { + let (response_tx, response_rx) = oneshot::channel(); + ( + Self { + envelope, + response_tx, + }, + response_rx, + ) + } +} From c7452526fa8f9e0ed021c47d9f53735c201d2c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Mon, 22 Jun 2026 18:27:30 +0200 Subject: [PATCH 146/166] test: Test filtermail-transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds filtermail-transport tests and a test setup for recording SMTP transactions. Signed-off-by: Jagoda Ślązak --- filtermail/Cargo.lock | 86 ++++++++ filtermail/Cargo.toml | 2 + filtermail/src/inbound.rs | 30 ++- filtermail/src/main.rs | 13 +- filtermail/src/outbound.rs | 26 ++- filtermail/src/smtp_client.rs | 159 +++++++++------ filtermail/src/smtp_server.rs | 109 +++++----- ...ail__transport__tests__smtp_send_mail.snap | 64 ++++++ ...ransport__tests__smtp_send_mail_defer.snap | 73 +++++++ filtermail/src/tcp.rs | 59 ++++++ filtermail/src/tcp/rec_stream.rs | 127 ++++++++++++ filtermail/src/transport.rs | 188 +++++++++++++++++- filtermail/src/transport/worker.rs | 55 +++-- 13 files changed, 834 insertions(+), 157 deletions(-) create mode 100644 filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail.snap create mode 100644 filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_defer.snap create mode 100644 filtermail/src/tcp.rs create mode 100644 filtermail/src/tcp/rec_stream.rs diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 8bce1434..27bec1f5 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -260,6 +260,17 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -465,6 +476,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -559,6 +576,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", + "insta", "log", "lru", "mailparse", @@ -567,6 +585,7 @@ dependencies = [ "retainer", "rstest", "serde", + "serial_test", "serini", "testresult", "thiserror", @@ -631,6 +650,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -1148,6 +1178,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "ipconfig" version = "0.3.2" @@ -2002,6 +2044,31 @@ dependencies = [ "zmij", ] +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serini" version = "0.2.2" @@ -2065,6 +2132,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "slab" version = "0.4.11" @@ -2183,6 +2256,19 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "testresult" version = "0.4.1" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 6d108c27..8d425e25 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -54,6 +54,8 @@ hyper-rustls = { version = "0.27.9", default-features = false, features = [ [dev-dependencies] rstest = "0.26.1" testresult = "0.4.1" +insta = "1.48.0" +serial_test = "3.5.0" [profile.release] lto = "thin" diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 9328e58d..d0ea23ed 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -7,6 +7,7 @@ use crate::smtp_client::SmtpConnectionPool; use crate::smtp_responses::ENCRYPTION_NEEDED_523; pub use crate::smtp_server::Envelope; use crate::smtp_server::{SmtpHandler, Transaction}; +use crate::tcp::{TcpConnect, TcpStreamTrait}; use crate::utils::{AddressDomain, build_resolver, extract_address, log_eml}; use async_trait::async_trait; use hickory_resolver::TokioResolver; @@ -15,15 +16,19 @@ use std::str::FromStr; use std::sync::Arc; /// Handler for incoming SMTP messages. -pub struct IncomingBeforeQueueHandler { +pub struct IncomingBeforeQueueHandler { config: Config, dns_resolver: Arc, dkim_verifier: DkimVerifier, skip_dkim: bool, - smtp_connection_pool: Arc, + smtp_connection_pool: Arc>, } -impl IncomingBeforeQueueHandler { +impl IncomingBeforeQueueHandler +where + S: TcpStreamTrait + TcpConnect, + S::ConnectionContext: Default, +{ pub fn new(config: Config, skip_dkim: bool) -> Result { let dns_resolver = Arc::new(build_resolver()?); Ok(Self { @@ -31,7 +36,7 @@ impl IncomingBeforeQueueHandler { dns_resolver: dns_resolver.clone(), dkim_verifier: DkimVerifier::new(dns_resolver), skip_dkim, - smtp_connection_pool: SmtpConnectionPool::new(), + smtp_connection_pool: SmtpConnectionPool::new(Default::default()), }) } @@ -68,7 +73,11 @@ impl IncomingBeforeQueueHandler { } #[async_trait] -impl SmtpHandler for IncomingBeforeQueueHandler { +impl SmtpHandler for IncomingBeforeQueueHandler +where + S: TcpStreamTrait + TcpConnect, + S::ConnectionContext: Default, +{ type State = (); async fn check_data(&self, transaction: &mut Transaction) -> Result<(), String> { @@ -151,12 +160,16 @@ impl SmtpHandler for IncomingBeforeQueueHandler { async fn reinject_mail(&self, transaction: &Transaction) -> Result<(), String> { log::debug!("Re-injecting the mail that passed checks"); let hostname = format!("[{}]", self.config.filtermail_host); + let client_config = crate::smtp_client::ClientConfig { + client_hostname: &hostname, + tls_config: None, + lmtp: false, + }; crate::smtp_client::send( &self.config.postfix_host, self.config.postfix_reinject_port_incoming, &transaction.envelope, - &hostname, - None, + client_config, self.dns_resolver.clone(), self.smtp_connection_pool.clone(), ) @@ -175,6 +188,7 @@ mod tests { use super::*; use rstest::{fixture, rstest}; use testresult::TestResult; + use tokio::net::TcpStream; #[fixture] fn config() -> Config { @@ -191,7 +205,7 @@ mod tests { #[case] address: &str, config: Config, ) -> TestResult { - let handler = IncomingBeforeQueueHandler::new(config, false)?; + let handler = IncomingBeforeQueueHandler::::new(config, false)?; let mut transaction = Transaction { envelope: Envelope { mail_from: address.to_string(), diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index aa159242..dcf5b2e6 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -35,6 +35,7 @@ pub(crate) mod outbound; pub(crate) mod smtp_client; mod smtp_responses; pub(crate) mod smtp_server; +mod tcp; mod tls; mod transport; pub(crate) mod utils; @@ -50,6 +51,7 @@ use std::env; use std::process; use std::str::FromStr; use std::sync::Arc; +use tokio::net::TcpStream; #[derive(Debug, Copy, Clone, PartialEq, Eq)] enum Mode { @@ -120,7 +122,9 @@ async fn main() -> Result<(), error::Error> { match mode { Mode::Outgoing => { let addr = (config.filtermail_host, config.filtermail_smtp_port); - let handler = Arc::new(OutgoingBeforeQueueHandler::new(config.clone())?); + let handler = Arc::new(OutgoingBeforeQueueHandler::::new( + config.clone(), + )?); let max_size = config.max_message_size; log::debug!("Outgoing SMTP server listening on {}:{}", addr.0, addr.1); @@ -139,7 +143,10 @@ async fn main() -> Result<(), error::Error> { log::warn!("DKIM verification DISABLED! This should not be used in production."); } - let handler = Arc::new(IncomingBeforeQueueHandler::new(config.clone(), skip_dkim)?); + let handler = Arc::new(IncomingBeforeQueueHandler::::new( + config.clone(), + skip_dkim, + )?); let max_size = config.max_message_size; let mut server_set = tokio::task::JoinSet::new(); @@ -177,7 +184,7 @@ async fn main() -> Result<(), error::Error> { config.filtermail_host, config.filtermail_lmtp_port_transport, ); - let handler = Arc::new(TransportHandler::new(config.clone())?); + let handler = Arc::new(TransportHandler::::new(config.clone())?); let max_size = config.max_message_size; log::debug!("Transport SMTP server listening on {}:{}", addr.0, addr.1); diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 5fc96a42..170e5466 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -6,6 +6,7 @@ use crate::smtp_client::SmtpConnectionPool; use crate::smtp_responses::ENCRYPTION_NEEDED_523; use crate::smtp_responses::OK_250; use crate::smtp_server::{SmtpHandler, Transaction}; +use crate::tcp::{TcpConnect, TcpStreamTrait}; use crate::utils::{build_resolver, extract_address}; use async_trait::async_trait; use governor::clock::MonotonicClock; @@ -16,7 +17,7 @@ use mailparse::{MailHeaderMap, parse_mail}; use std::sync::Arc; /// Handler for outgoing SMTP messages. -pub struct OutgoingBeforeQueueHandler { +pub struct OutgoingBeforeQueueHandler { config: Config, dns_resolver: Arc, @@ -33,10 +34,14 @@ pub struct OutgoingBeforeQueueHandler { MonotonicClock, NoOpMiddleware, >, - smtp_connection_pool: Arc, + smtp_connection_pool: Arc>, } -impl OutgoingBeforeQueueHandler { +impl OutgoingBeforeQueueHandler +where + S: TcpStreamTrait + TcpConnect, + S::ConnectionContext: Default, +{ pub fn new(config: Config) -> Result { let quota = Quota::per_minute(config.max_user_send_per_minute) .allow_burst(config.max_user_send_burst_size); @@ -46,13 +51,16 @@ impl OutgoingBeforeQueueHandler { config, dns_resolver, send_rate_limiter, - smtp_connection_pool: SmtpConnectionPool::new(), + smtp_connection_pool: SmtpConnectionPool::new(Default::default()), }) } } #[async_trait] -impl SmtpHandler for OutgoingBeforeQueueHandler { +impl SmtpHandler for OutgoingBeforeQueueHandler +where + S: TcpStreamTrait + TcpConnect, +{ type State = (); fn handle_mail_from(&self, address: &str) -> Result<(), String> { @@ -161,12 +169,16 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { async fn reinject_mail(&self, transaction: &Transaction) -> Result<(), String> { log::debug!("Re-injecting the mail that passed checks"); let hostname = format!("[{}]", self.config.filtermail_host); + let client_config = crate::smtp_client::ClientConfig { + client_hostname: &hostname, + tls_config: None, + lmtp: false, + }; crate::smtp_client::send( &self.config.postfix_host, self.config.postfix_reinject_port, &transaction.envelope, - &hostname, - None, + client_config, self.dns_resolver.clone(), self.smtp_connection_pool.clone(), ) diff --git a/filtermail/src/smtp_client.rs b/filtermail/src/smtp_client.rs index 2227942b..46048459 100644 --- a/filtermail/src/smtp_client.rs +++ b/filtermail/src/smtp_client.rs @@ -1,4 +1,5 @@ use crate::smtp_server::Envelope; +use crate::tcp::{TcpConnect, TcpStreamTrait}; use hickory_resolver::TokioResolver; use std::pin::Pin; use std::sync::Arc; @@ -6,14 +7,13 @@ use std::task::{Context, Poll}; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufStream}; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; -use tokio::net::TcpStream; use tokio::task::{JoinHandle, JoinSet}; use tokio_io_timeout::TimeoutStream; use tokio_rustls::rustls::client::ClientSessionMemoryCache; /// Wraps SMTP connection, contains stream and ESMTP support information. -pub struct SmtpConnection { - pub stream: BufStream, +pub struct SmtpConnection { + pub stream: BufStream>, pub pipelining: bool, } @@ -22,14 +22,21 @@ pub struct SmtpConnection { /// Connections are cached for up to 100 seconds of idle time. /// /// Only a single connection is cached per address/port pair. -pub struct SmtpConnectionPool { - pool: Arc>, +pub struct SmtpConnectionPool +where + S: TcpStreamTrait + TcpConnect, +{ + pool: Arc>>, monitor_handle: JoinHandle<()>, + context: S::ConnectionContext, } -impl SmtpConnectionPool { +impl SmtpConnectionPool +where + S: TcpStreamTrait + TcpConnect, +{ /// Creates a new connection pool and starts the cache monitoring task. - pub fn new() -> Arc { + pub fn new(context: S::ConnectionContext) -> Arc { let pool = Arc::new(retainer::Cache::new()); let pool_clone = pool.clone(); @@ -39,16 +46,17 @@ impl SmtpConnectionPool { Arc::new(Self { pool, monitor_handle, + context, }) } /// Takes a connection from the pool for the given address and port, if available. - pub async fn take(&self, address: &str, port: u16) -> Option { + pub async fn take(&self, address: &str, port: u16) -> Option> { self.pool.remove(&(address.to_string(), port)).await } /// Puts a connection into the pool for the given address and port, with a 100s timeout. - pub async fn put(&self, address: &str, port: u16, connection: SmtpConnection) { + pub async fn put(&self, address: &str, port: u16, connection: SmtpConnection) { // similarly to postfix default -> 100s max idle time. self.pool .insert( @@ -60,25 +68,25 @@ impl SmtpConnectionPool { } } -impl Drop for SmtpConnectionPool { +impl Drop for SmtpConnectionPool { fn drop(&mut self) { self.monitor_handle.abort(); } } -/// A [`TcpStream`] used for SMTP communication. +/// A [`TcpStream`] wrapper used for SMTP communication. #[expect(clippy::large_enum_variant)] -pub enum SmtpStream { +pub enum SmtpStream { /// A plain TCP stream. - Plain(Pin>>), + Plain(Pin>>), /// A TLS-encrypted stream. - Tls(tokio_rustls::TlsStream>>>), + Tls(tokio_rustls::TlsStream>>>), } -impl SmtpStream { +impl SmtpStream { /// Creates a new plain SMTP stream from a raw TCP stream, /// with read and write timeouts set to 60 seconds. - fn plain(stream: TcpStream) -> Self { + fn plain(stream: S) -> Self { let mut timeout_stream = TimeoutStream::new(stream); timeout_stream.set_write_timeout(Some(Duration::from_secs(60))); timeout_stream.set_read_timeout(Some(Duration::from_secs(60))); @@ -127,7 +135,7 @@ pub struct TlsConfig { pub(crate) session_cache: Arc, } -impl AsyncWrite for SmtpStream { +impl AsyncWrite for SmtpStream { fn poll_write( self: Pin<&mut Self>, cx: &mut Context<'_>, @@ -154,7 +162,7 @@ impl AsyncWrite for SmtpStream { } } -impl AsyncRead for SmtpStream { +impl AsyncRead for SmtpStream { fn poll_read( self: Pin<&mut Self>, cx: &mut Context<'_>, @@ -189,25 +197,31 @@ async fn to_socket_addrs( } /// Establishes a TCP connection to the given address and port, trying all resolved IPs in parallel. -async fn establish_tcp_connection( +async fn establish_tcp_connection( address: &str, port: u16, dns_resolver: Arc, -) -> Result { - let mut set: JoinSet> = JoinSet::new(); + context: S::ConnectionContext, +) -> Result +where + S: TcpStreamTrait + TcpConnect, +{ + let mut set: JoinSet> = JoinSet::new(); let socket_addrs = to_socket_addrs(address, port, dns_resolver).await?; for addr in socket_addrs.clone() { + let context_clone = context.clone(); set.spawn(async move { log::trace!("SMTP client: connecting to {addr}..."); - let stream = - tokio::time::timeout(Duration::from_secs(60), TcpStream::connect(addr)).await??; + let stream: S = + tokio::time::timeout(Duration::from_secs(60), S::connect(addr, context_clone)) + .await??; stream.set_nodelay(true)?; Ok(stream) }); } - let mut stream: Option = None; + let mut stream: Option = None; while let Some(result) = set.join_next().await { match result { Ok(Ok(s)) => { @@ -227,46 +241,60 @@ async fn establish_tcp_connection( } } +/// SMTP/LMTP client configuration options. +pub struct ClientConfig<'a> { + /// Client hostname used for greeting + pub client_hostname: &'a str, + + /// If [`Some`], the connection will be upgraded to TLS. + /// The client will fail early if the server does not support STARTTLS. + pub tls_config: Option, + + /// If `true`, switches to `LHLO` greeting and returns per-recipient composite response. + pub lmtp: bool, +} + /// Sends an email using an SMTP server at `smtp_addr`. -/// -/// If `tls_config` is provided, the connection will be upgraded to TLS. -/// The client will fail early if the server does not support STARTTLS. -/// /// If `address` is a domain that resolves to multiple IP addresses, /// all will be tried in parallel and the first successful connection will be used. /// /// `pool` is used to reuse existing connections to the same address and port, if available. -pub async fn send( +pub async fn send( address: &str, port: u16, envelope: &Envelope, - client_hostname: &str, - tls_config: Option, + config: ClientConfig<'_>, dns_resolver: Arc, - pool: Arc, -) -> Result<(), crate::error::Error> { - let (mut buf_stream, reused, mut pipelining) = if let Some(connection) = - pool.take(address, port).await - { - log::debug!( - "Reusing existing connection to {}", - connection.stream.get_ref().format_host(address) - ); - if tls_config.is_some() { - // This should never happen, - // assert to make sure we never accidentally use a plain connection while expecting TLS. - assert!( - matches!(connection.stream.get_ref(), SmtpStream::Tls(_)), - "Expected TLS stream from pool, but got plain stream." + pool: Arc>, +) -> Result<(), crate::error::Error> +where + S: TcpStreamTrait + TcpConnect, +{ + let greeting = if config.lmtp { "LHLO" } else { "EHLO" }; + + let (mut buf_stream, reused, mut pipelining) = + if let Some(connection) = pool.take(address, port).await { + log::debug!( + "Reusing existing connection to {}", + connection.stream.get_ref().format_host(address) ); - } - (connection.stream, true, connection.pipelining) - } else { - let stream = - SmtpStream::plain(establish_tcp_connection(address, port, dns_resolver.clone()).await?); - log::debug!("Successfully connected to {}", stream.format_host(address)); - (BufStream::new(stream), false, false) - }; + if config.tls_config.is_some() { + // This should never happen, + // assert to make sure we never accidentally use a plain connection while expecting TLS. + assert!( + matches!(connection.stream.get_ref(), SmtpStream::Tls(_)), + "Expected TLS stream from pool, but got plain stream." + ); + } + (connection.stream, true, connection.pipelining) + } else { + let stream = SmtpStream::plain( + establish_tcp_connection(address, port, dns_resolver.clone(), pool.context.clone()) + .await?, + ); + log::debug!("Successfully connected to {}", stream.format_host(address)); + (BufStream::new(stream), false, false) + }; let mut response = String::new(); @@ -329,7 +357,8 @@ pub async fn send( // e.g.: 421 example.org Service closing transmission channel - command timeout if response.starts_with("421") { log::debug!("Reused connection is dead; establishing new connection..."); - let stream = establish_tcp_connection(address, port, dns_resolver).await?; + let stream: S = + establish_tcp_connection(address, port, dns_resolver, pool.context.clone()).await?; log::debug!("Successfully connected to {}", stream.peer_addr()?); buf_stream = BufStream::new(SmtpStream::plain(stream)); false @@ -346,8 +375,8 @@ pub async fn send( smtp_read!("initial greeting", "220")?; smtp_cmd!( - format!("EHLO {client_hostname}\r\n").as_bytes(), - "EHLO", + format!("{greeting} {}\r\n", { config.client_hostname }).as_bytes(), + greeting, "250" )?; @@ -358,7 +387,7 @@ pub async fn send( } // ESMTP: STARTTLS - if let Some(tls_config) = tls_config { + if let Some(tls_config) = config.tls_config { if !response.to_uppercase().contains("STARTTLS") { // TLS was requested, but server doesn't support STARTTLS. return Err(crate::error::Error::MailSend { @@ -392,7 +421,7 @@ pub async fn send( buf_stream = BufStream::new(smtp_stream); smtp_cmd!( - format!("EHLO {client_hostname}\r\n").as_bytes(), + format!("EHLO {}\r\n", config.client_hostname).as_bytes(), "EHLO after STARTTLS", "250" )?; @@ -457,7 +486,14 @@ pub async fn send( } smtp_write!(&envelope.data); - smtp_cmd!(b".\r\n", "end of DATA", "250")?; + smtp_write!(b".\r\n"); + if config.lmtp { + for _ in 0..envelope.rcpt_to.len() { + smtp_read!("end of DATA", "250")?; + } + } else { + smtp_read!("end of DATA", "250")?; + } pool.put( address, @@ -477,6 +513,7 @@ mod tests { use super::*; use rstest::rstest; use std::net::SocketAddr; + use tokio::net::TcpStream; #[rstest] #[case::ipv4("192.0.2.0:25".parse().ok(), "192.0.2.0", "192.0.2.0:25")] @@ -489,7 +526,7 @@ mod tests { #[case] host: &str, #[case] expected: &str, ) { - let result = SmtpStream::format_host_inner(host, socket_addr); + let result = SmtpStream::::format_host_inner(host, socket_addr); assert_eq!(result, expected); } } diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index f1458caf..59c7e4a2 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -7,8 +7,8 @@ use memchr::{Memchr, memmem}; use std::fmt::Debug; use std::sync::Arc; use std::time::Duration; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; -use tokio::net::{TcpListener, TcpStream}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufStream}; +use tokio::net::TcpListener; /// Represents an SMTP envelope with sender, recipients, and raw message data. #[derive(Debug, Default, Clone)] @@ -139,10 +139,19 @@ pub trait SmtpHandler: Send + Sync { log::warn!("Failed to reinject mail: {e}"); e })?; - Ok("OK_250".to_string()) + Ok(OK_250.to_string()) } } +/// A mockup handler that does nothing. +#[cfg(test)] +pub struct MockHandler; + +#[cfg(test)] +impl SmtpHandler for MockHandler { + type State = (); +} + /// Runs the SMTP server on the specified address with the given handler and maximum message size. pub async fn run_smtp_server( addr: &impl tokio::net::ToSocketAddrs, @@ -164,7 +173,7 @@ where let handler = handler.clone(); tokio::spawn(async move { - if let Err(e) = handle_connection(socket, handler, max_size).await { + if let Err(e) = handle_connection(socket, handler, max_size, false).await { log::error!("Error handling connection: {e}"); } }); @@ -180,27 +189,36 @@ where } /// Handles an individual SMTP connection. -async fn handle_connection( - socket: TcpStream, +/// +/// Setting `auto_quit` to `true` will automatically close connection after receiving the first +/// message. Should be used only for tests, it's not a behavior described by SMTP spec. +pub(crate) async fn handle_connection( + stream: S, handler: Arc, max_size: usize, + auto_quit: bool, ) -> Result<(), Box> where + S: AsyncWrite + AsyncRead + Unpin, H: SmtpHandler, { - let (reader, writer) = socket.into_split(); - let mut reader = BufReader::new(reader); - let mut writer = BufWriter::new(writer); + let mut bufstream = BufStream::new(stream); let mut line = String::new(); - writer.write_all(b"220 filtermail SMTP\r\n").await?; - writer.flush().await?; + macro_rules! smtp_write { + ($($arg:tt)*) => { + bufstream.write_all(format!($($arg)*).as_bytes()).await?; + bufstream.flush().await?; + }; + } + + smtp_write!("220 filtermail SMTP\r\n"); let mut transaction = Transaction::default(); 'connection: loop { line.clear(); - let n = reader.read_line(&mut line).await?; + let n = bufstream.read_line(&mut line).await?; if n == 0 { break 'connection; } @@ -218,66 +236,50 @@ where log::debug!("Received: {cmd}"); if cmd.to_uppercase().starts_with("HELO") { - writer.write_all(b"250-filtermail\r\n250 OK\r\n").await?; - writer.flush().await?; + smtp_write!("250-filtermail\r\n250 OK\r\n"); } else if cmd.to_uppercase().starts_with("EHLO") // We support LMTP, but it's not validated; // service that expects LMTP will send LMTP responses no matter the greeting. // Sufficient for our internal use case. || cmd.to_uppercase().starts_with("LHLO") { - writer - .write_all(b"250-filtermail\r\n250-8BITMIME\r\n250 OK\r\n") - .await?; - writer.flush().await?; + smtp_write!("250-filtermail\r\n250-8BITMIME\r\n250 OK\r\n"); } else if cmd.to_uppercase().starts_with("MAIL FROM:<>") { // bounce message transaction.envelope.mail_from = String::new(); - writer.write_all(format!("{OK_250}\r\n").as_bytes()).await?; - writer.flush().await?; + smtp_write!("{OK_250}\r\n"); } else if cmd.to_uppercase().starts_with("MAIL FROM:") { if let Some(from) = extract_address(cmd) { if let Err(e) = handler.handle_mail_from(&from) { - writer.write_all(format!("{}\r\n", e).as_bytes()).await?; - writer.flush().await?; + smtp_write!("{}\r\n", e); continue 'connection; } transaction.envelope.mail_from = from; - writer.write_all(format!("{OK_250}\r\n").as_bytes()).await?; - writer.flush().await?; + smtp_write!("{OK_250}\r\n"); } else { log::warn!("Invalid MAIL FROM command. Can't extract address. Received: {cmd}"); - writer - .write_all(b"500 Invalid address in MAIL FROM\r\n") - .await?; - writer.flush().await?; + smtp_write!("500 Invalid address in MAIL FROM\r\n"); } } else if cmd.to_uppercase().starts_with("RCPT TO:") { if let Some(to) = extract_address(cmd) { if let Err(e) = handler.handle_rcpt_to(&to, &mut transaction) { - writer.write_all(format!("{}\r\n", e).as_bytes()).await?; - writer.flush().await?; + smtp_write!("{}\r\n", e); continue 'connection; } transaction.envelope.rcpt_to.push(to); - writer.write_all(format!("{OK_250}\r\n").as_bytes()).await?; - writer.flush().await?; + smtp_write!("{OK_250}\r\n"); } } else if cmd.to_uppercase().starts_with("DATA") { if let Err(e) = handler.handle_data_start(&transaction) { - writer.write_all(format!("{}\r\n", e).as_bytes()).await?; - writer.flush().await?; + smtp_write!("{}\r\n", e); continue 'connection; } - writer - .write_all(b"354 End data with .\r\n") - .await?; - writer.flush().await?; + smtp_write!("354 End data with .\r\n"); let mut data = Vec::new(); let mut data_line = String::new(); 'data_read: loop { data_line.clear(); - if reader.read_line(&mut data_line).await? == 0 { + if bufstream.read_line(&mut data_line).await? == 0 { log::warn!("Unexpected EoF while receiving DATA! Closing connection."); break 'connection; } @@ -303,10 +305,7 @@ where data.extend_from_slice(data_line.as_bytes()); if data.len() > max_size { - writer - .write_all(b"552 Message exceeds maximum size\r\n") - .await?; - writer.flush().await?; + smtp_write!("552 Message exceeds maximum size\r\n"); continue 'connection; } } @@ -317,33 +316,27 @@ where match handler.handle_data_dot(&mut transaction).await { Ok(response) => { log::debug!("Sent: {response}"); - writer - .write_all(format!("{}\r\n", response).as_bytes()) - .await?; - writer.flush().await?; + smtp_write!("{}\r\n", response); } Err(e) => { log::debug!("Sent: {e}"); - writer.write_all(format!("{}\r\n", e).as_bytes()).await?; - writer.flush().await?; + smtp_write!("{}\r\n", e); } } - + if auto_quit { + break 'connection; + } transaction = Transaction::default(); } else if cmd.to_uppercase().starts_with("QUIT") { - writer.write_all(b"221 OK\r\n").await?; - writer.flush().await?; + smtp_write!("221 OK\r\n"); break 'connection; } else if cmd.to_uppercase().starts_with("RSET") { transaction = Transaction::default(); - writer.write_all(format!("{OK_250}\r\n").as_bytes()).await?; - writer.flush().await?; + smtp_write!("{OK_250}\r\n"); } else if cmd.to_uppercase().starts_with("NOOP") { - writer.write_all(format!("{OK_250}\r\n").as_bytes()).await?; - writer.flush().await?; + smtp_write!("{OK_250}\r\n"); } else { - writer.write_all(b"500 Command not recognized\r\n").await?; - writer.flush().await?; + smtp_write!("500 Command not recognized\r\n"); } } diff --git a/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail.snap b/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail.snap new file mode 100644 index 00000000..46bc4567 --- /dev/null +++ b/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail.snap @@ -0,0 +1,64 @@ +--- +source: src/transport.rs +expression: "format!(\"[postfix -> filtermail-transport]\\r\\n{record}\\r\\n\\\n [filtermail-transport -> destination A]\\r\\n{}\\r\\n\\\n [filtermail-transport -> destination B]\\r\\n{}\",\nremote_records[0], remote_records[1])" +--- +[postfix -> filtermail-transport] +< 220 filtermail SMTP +> LHLO postfix +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> RCPT TO: +< 250 OK +> RCPT TO: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +> . +< 250 OK (SMTP) +250 OK (SMTP) +250 OK (SMTP) +250 OK (SMTP) + +[filtermail-transport -> destination A] +< 220 filtermail SMTP +> EHLO example.org +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +. +< 250 OK + +[filtermail-transport -> destination B] +< 220 filtermail SMTP +> EHLO example.org +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +. +< 250 OK diff --git a/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_defer.snap b/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_defer.snap new file mode 100644 index 00000000..9dd43b2f --- /dev/null +++ b/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_defer.snap @@ -0,0 +1,73 @@ +--- +source: src/transport.rs +expression: "format!(\"TRANSACTION 1\\r\\n\\\n [postfix -> filtermail-transport]\\r\\n{record_postfix_1}\\r\\n\\\n [filtermail-transport -> destination A]\\r\\n{record_filtermail_1}\\r\\n\\r\\n\\\n TRANSACTION 2\\r\\n\\\n [postfix -> filtermail-transport]\\r\\n{record_postfix_2}\\r\\n\\\n [filtermail-transport -> destination B]\\r\\n{record_filtermail_2}\",)" +--- +TRANSACTION 1 +[postfix -> filtermail-transport] +< 220 filtermail SMTP +> LHLO postfix +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +> . +< 250 OK (SMTP) +421 Worker for this destination is busy + +[filtermail-transport -> destination A] +< 220 filtermail SMTP +> EHLO example.org +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +. +< 250 OK + + +TRANSACTION 2 +[postfix -> filtermail-transport] +< 220 filtermail SMTP +> LHLO postfix +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +> . +< 250 OK (SMTP) + +[filtermail-transport -> destination B] +< 220 filtermail SMTP +> EHLO example.org +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +. +< 250 OK diff --git a/filtermail/src/tcp.rs b/filtermail/src/tcp.rs new file mode 100644 index 00000000..dbc74182 --- /dev/null +++ b/filtermail/src/tcp.rs @@ -0,0 +1,59 @@ +//! TCP related code. + +use async_trait::async_trait; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::net::{TcpStream, ToSocketAddrs}; + +#[cfg(test)] +pub mod rec_stream; + +/// Abstraction over [`TcpStream`] allowing e.g. mocking it. +pub trait TcpStreamTrait: AsyncRead + AsyncWrite + Unpin + Send + Sync + Sized + 'static { + /// Returns the remote address that this stream is connected to. + fn peer_addr(&self) -> std::io::Result; + + /// Sets the value of the `TCP_NODELAY` option on this socket. + fn set_nodelay(&self, nodelay: bool) -> std::io::Result<()>; +} + +/// Trait adding a `connect` method similar to [`TcpStream::connect`], +/// that allows passing additional context when creating the stream. +#[async_trait] +pub trait TcpConnect: TcpStreamTrait { + /// Type of additional context passed to [`TcpConnect::connect`]. + type ConnectionContext: Send + Sync + Clone + 'static; + + /// Opens a TCP connection to a remote host. + async fn connect( + addr: A, + context: Self::ConnectionContext, + ) -> std::io::Result; +} + +impl TcpStreamTrait for TcpStream { + /// Returns the remote address that this stream is connected to. + /// + /// Delegates to [`TcpStream::peer_addr`]. + fn peer_addr(&self) -> std::io::Result { + TcpStream::peer_addr(self) + } + + /// Sets the value of the `TCP_NODELAY` option on this socket. + /// + /// Delegates to [`TcpStream::set_nodelay`]. + fn set_nodelay(&self, nodelay: bool) -> std::io::Result<()> { + TcpStream::set_nodelay(self, nodelay) + } +} + +#[async_trait] +impl TcpConnect for TcpStream { + type ConnectionContext = (); + + /// Opens a TCP connection to a remote host. + /// + /// Delegates to [`TcpStream::connect`]. + async fn connect(addr: A, _: ()) -> std::io::Result { + TcpStream::connect(addr).await + } +} diff --git a/filtermail/src/tcp/rec_stream.rs b/filtermail/src/tcp/rec_stream.rs new file mode 100644 index 00000000..7ae8240d --- /dev/null +++ b/filtermail/src/tcp/rec_stream.rs @@ -0,0 +1,127 @@ +//! [`TcpStreamTrait`] implementation that records communication. +//! +//! Used for snapshot testing. + +use super::{TcpConnect, TcpStreamTrait}; +use async_trait::async_trait; +use std::net::SocketAddr; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::{TcpStream, ToSocketAddrs}; +use tokio::sync::mpsc::Sender; + +/// A stream that behaves similarly to [`TcpStream`], +/// but additionally records the whole conversation to internal buffer, +/// and sends it over [`Sender`] when dropped. +pub struct RecTcpStream { + tx: Sender, + inner: TcpStream, + rec_buffer: String, + // read/write arrows + arrows: (char, char), +} + +impl RecTcpStream { + /// Creates a new [`RecTcpStream`]. + /// + /// Recorded conversation will be sent over `tx`. + /// + /// Setting `is_server` to `true`, will invert read/write arrow characters, + /// so that they correctly used for `> client command` and `< server response`. + pub fn new(stream: TcpStream, tx: Sender, is_server: bool) -> Self { + Self { + tx, + inner: stream, + rec_buffer: String::new(), + arrows: match is_server { + true => ('>', '<'), + false => ('<', '>'), + }, + } + } +} + +impl AsyncWrite for RecTcpStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let mut_self = self.get_mut(); + let inner_result = Pin::new(&mut mut_self.inner).poll_write(cx, buf); + if inner_result.is_pending() { + return inner_result; + } + + if !buf.is_empty() { + let mut data = String::from_utf8_lossy(buf).to_string(); + data = format!("{} {data}", mut_self.arrows.1); + mut_self.rec_buffer.push_str(&data); + } + + inner_result + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) + } +} + +impl AsyncRead for RecTcpStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let mut_self = self.get_mut(); + let inner_result = Pin::new(&mut mut_self.inner).poll_read(cx, buf); + if inner_result.is_pending() { + return inner_result; + } + + let filled = buf.filled(); + if !filled.is_empty() { + let mut data = String::from_utf8_lossy(filled).to_string(); + data = format!("{} {data}", mut_self.arrows.0); + mut_self.rec_buffer.push_str(&data); + } + + inner_result + } +} + +impl Drop for RecTcpStream { + fn drop(&mut self) { + let tx = self.tx.clone(); + let rec_buffer = self.rec_buffer.clone(); + tokio::spawn(async move { tx.send(rec_buffer).await.unwrap() }); + } +} + +impl TcpStreamTrait for RecTcpStream { + fn peer_addr(&self) -> std::io::Result { + self.inner.peer_addr() + } + + fn set_nodelay(&self, nodelay: bool) -> std::io::Result<()> { + self.inner.set_nodelay(nodelay) + } +} + +#[async_trait] +impl TcpConnect for RecTcpStream { + type ConnectionContext = Sender; + + async fn connect( + addr: A, + tx: Sender, + ) -> std::io::Result { + let inner = TcpStream::connect(addr).await?; + Ok(Self::new(inner, tx, false)) + } +} diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs index 0d02a46b..1a9e1678 100644 --- a/filtermail/src/transport.rs +++ b/filtermail/src/transport.rs @@ -4,6 +4,7 @@ mod worker; use crate::config::Config; use crate::smtp_responses::{LOCAL_ERROR_451, WORKER_BUSY_421}; use crate::smtp_server::{SmtpHandler, Transaction}; +use crate::tcp::{TcpConnect, TcpStreamTrait}; use crate::utils::AddressDomain; use async_trait::async_trait; use std::collections::BTreeMap; @@ -15,11 +16,15 @@ use worker::{WorkerMessage, WorkerPool}; pub const HEADER_MAIL_FROM: &str = "X-MAIL-FROM"; pub const HEADER_RCPT_TO: &str = "X-MAIL-TO"; -pub struct TransportHandler { - workers: WorkerPool, +pub struct TransportHandler { + workers: WorkerPool, } -impl TransportHandler { +impl TransportHandler +where + S: TcpStreamTrait + TcpConnect, + S::ConnectionContext: Default, +{ /// Creates a new [`TransportHandler`]. pub fn new(config: Config) -> Result { let workers = WorkerPool::new(config)?; @@ -44,7 +49,11 @@ pub struct TransactionState { } #[async_trait] -impl SmtpHandler for TransportHandler { +impl SmtpHandler for TransportHandler +where + S: TcpStreamTrait + TcpConnect, + S::ConnectionContext: Default, +{ type State = TransactionState; fn handle_rcpt_to( @@ -190,8 +199,90 @@ impl SmtpHandler for TransportHandler { #[cfg(test)] mod tests { use super::*; + use crate::smtp_client::SmtpConnectionPool; + use crate::smtp_server::{Envelope, MockHandler, run_smtp_server}; + use crate::tcp::rec_stream::RecTcpStream; use rstest::{fixture, rstest}; + use serial_test::serial; + use std::sync::Arc; + use std::time::Duration; use testresult::TestResult; + use tokio::net::{TcpSocket, TcpStream}; + use tokio::sync::mpsc::Receiver; + + const FILTERMAIL_IP: &str = "127.0.0.1"; + const FILTERMAIL_PORT: u16 = 10083; + const FILTERMAIL_ADDR: (&str, u16) = (FILTERMAIL_IP, FILTERMAIL_PORT); + + /// Spawns a mockup SMTP server that accepts anything on `localhost:10025`. + /// + /// Returns a receiver that receives records of SMTP conversations. + fn spawn_mock_mta() -> TestResult> { + let socket = TcpSocket::new_v4()?; + socket.set_nodelay(true)?; + socket.set_reuseport(true)?; + socket.bind("127.0.0.1:10025".parse()?)?; + let remote_listener = socket.listen(8)?; + let (tx, rx) = tokio::sync::mpsc::channel(128); + tokio::spawn(async move { + while let Ok((stream, _)) = remote_listener.accept().await { + let tx_clone = tx.clone(); + let rec_stream = RecTcpStream::new(stream, tx_clone, true); + tokio::spawn(async move { + crate::smtp_server::handle_connection( + rec_stream, + Arc::new(MockHandler), + 9999, // arbitrary + true, + ) + .await + .unwrap(); + }); + } + }); + Ok(rx) + } + + /// Spawns filtermail-transport. + /// + /// Returns a pointer to the underlying handler. + fn spawn_filtermail_transport() -> TestResult>> { + let config = Config::default(); + let transport = Arc::new(TransportHandler::with_queue_size(config.clone(), 1)?); + tokio::spawn(run_smtp_server( + &FILTERMAIL_ADDR, + transport.clone(), + config.max_message_size, + )); + Ok(transport) + } + + /// Sends envelope over LMTP. + /// + /// Returns a recorded LMTP conversation. + /// + /// Does not fail on negative response. + async fn lmtp_send(envelope: &Envelope) -> TestResult { + let (tx, mut rx) = tokio::sync::mpsc::channel(128); + let client_config = crate::smtp_client::ClientConfig { + client_hostname: "postfix", + tls_config: None, + lmtp: true, + }; + let _ = crate::smtp_client::send( + FILTERMAIL_IP, + FILTERMAIL_PORT, + envelope, + client_config, + Arc::new(crate::utils::build_resolver()?), + SmtpConnectionPool::::new(tx), + ) + .await; + + let record = rx.recv().await.unwrap(); + + Ok(record) + } #[fixture] fn addrs1() -> Vec { @@ -214,7 +305,8 @@ mod tests { #[rstest] #[tokio::test] async fn test_rcpt_to_and_start_data(addrs1: Vec, addrs2: Vec) -> TestResult { - let transport_handler = TransportHandler::with_queue_size(Config::default(), 1)?; + let transport_handler = + TransportHandler::::with_queue_size(Config::default(), 1)?; let domain1 = AddressDomain::from_str(addrs1.first().unwrap())?; let domain2 = AddressDomain::from_str(addrs2.first().unwrap())?; @@ -263,4 +355,90 @@ mod tests { Ok(()) } + + #[rstest] + #[serial] + #[tokio::test] + async fn test_smtp_send_mail() -> TestResult { + let mut remote_mta = spawn_mock_mta()?; + spawn_filtermail_transport()?; + + let envelope = Envelope { + mail_from: "sender@here".to_string(), + rcpt_to: vec![ + // Taking advantage of the fact that localhost and [127.0.0.1] are recognized as + // different destinations. + "a1@localhost".to_string(), + "a2@localhost".to_string(), + "b1@[127.0.0.1]".to_string(), + "b2@[127.0.0.1]".to_string(), + ], + data: "message\r\n".as_bytes().to_vec(), + }; + + let record = lmtp_send(&envelope).await?; + + let mut remote_records = [ + remote_mta.recv().await.unwrap(), + remote_mta.recv().await.unwrap(), + ]; + remote_records.sort_by_key(|s| s.contains("b1@[127.0.0.1]")); + tokio::time::sleep(Duration::from_secs(1)).await; + assert!(remote_mta.is_empty()); + + insta::assert_snapshot!(format!( + "[postfix -> filtermail-transport]\r\n{record}\r\n\ + [filtermail-transport -> destination A]\r\n{}\r\n\ + [filtermail-transport -> destination B]\r\n{}", + remote_records[0], remote_records[1] + )); + + Ok(()) + } + + #[rstest] + #[serial] + #[tokio::test] + async fn test_smtp_send_mail_defer() -> TestResult { + let mut remote_mta = spawn_mock_mta()?; + let transport = spawn_filtermail_transport()?; + + let mut envelope = Envelope { + mail_from: "sender@here".to_string(), + rcpt_to: vec!["a1@localhost".to_string(), "b1@[127.0.0.1]".to_string()], + data: "message\r\n".as_bytes().to_vec(), + }; + + let (record_postfix_1, record_filtermail_1) = { + // simulate full queue on [127.0.0.1] worker + let _permit = transport + .workers + .get_permit(&AddressDomain::Literal("127.0.0.1".to_string())); + + let record_postfix = lmtp_send(&envelope).await?; + + let record_filtermail = remote_mta.recv().await.unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + assert!(remote_mta.is_empty()); + (record_postfix, record_filtermail) + }; + + // retry deferred + envelope.rcpt_to.remove(0); + let record_postfix_2 = lmtp_send(&envelope).await?; + let record_filtermail_2 = remote_mta.recv().await.unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + assert!(remote_mta.is_empty()); + + insta::assert_snapshot!(format!( + "TRANSACTION 1\r\n\ + [postfix -> filtermail-transport]\r\n{record_postfix_1}\r\n\ + [filtermail-transport -> destination A]\r\n{record_filtermail_1}\r\n\r\n\ + TRANSACTION 2\r\n\ + [postfix -> filtermail-transport]\r\n{record_postfix_2}\r\n\ + [filtermail-transport -> destination B]\r\n{record_filtermail_2}", + )); + + Ok(()) + } } diff --git a/filtermail/src/transport/worker.rs b/filtermail/src/transport/worker.rs index 64a199a3..3472f062 100644 --- a/filtermail/src/transport/worker.rs +++ b/filtermail/src/transport/worker.rs @@ -2,6 +2,7 @@ use crate::config::Config; use crate::smtp_client::{SmtpConnectionPool, TlsConfig}; use crate::smtp_responses::{OK_HTTPS_250, OK_SMTP_250}; use crate::smtp_server::Envelope; +use crate::tcp::{TcpConnect, TcpStreamTrait}; use crate::transport::{HEADER_MAIL_FROM, HEADER_RCPT_TO, https_client::HttpsClient}; use crate::utils::{AddressDomain, build_resolver}; use hickory_resolver::TokioResolver; @@ -18,6 +19,16 @@ use tokio::task; use tokio::task::JoinHandle; use tokio_rustls::rustls; +#[cfg(not(test))] +const SMTP_PORT: u16 = 25; +#[cfg(not(test))] +const SMTP_SKIP_TLS: bool = false; + +#[cfg(test)] +const SMTP_PORT: u16 = 10025; +#[cfg(test)] +const SMTP_SKIP_TLS: bool = true; + /// Message queue size per [`Worker`]. /// /// If a queue to a single destination reaches this limit, @@ -26,17 +37,21 @@ const PER_DESTINATION_QUEUE_SIZE: usize = 30; type SMTPResponse = Result; -pub struct WorkerPool { +pub struct WorkerPool { inner: RwLock>>, client_hostname: String, - smtp_connection_pool: Arc, + smtp_connection_pool: Arc>, mxdeliv_unsupported_hosts: Arc>, monitor_handle: JoinHandle<()>, dns_resolver: Arc, queue_size: usize, } -impl WorkerPool { +impl WorkerPool +where + S: TcpStreamTrait + TcpConnect, + S::ConnectionContext: Default, +{ pub fn new(config: Config) -> Result { let dns_resolver = Arc::new(build_resolver()?); @@ -53,7 +68,7 @@ impl WorkerPool { inner: Default::default(), client_hostname: config.mail_domain, dns_resolver, - smtp_connection_pool: SmtpConnectionPool::new(), + smtp_connection_pool: SmtpConnectionPool::::new(Default::default()), mxdeliv_unsupported_hosts: mxdeliv_cache, monitor_handle, queue_size: PER_DESTINATION_QUEUE_SIZE, @@ -131,7 +146,7 @@ impl WorkerPool { } } -impl Drop for WorkerPool { +impl Drop for WorkerPool { fn drop(&mut self) { self.monitor_handle.abort(); } @@ -150,14 +165,17 @@ impl Drop for Worker { } impl Worker { - pub async fn run( + pub async fn run( destination: AddressDomain, mut rx: mpsc::Receiver, client_hostname: String, - smtp_connection_pool: Arc, + smtp_connection_pool: Arc>, mxdeliv_unsupported_hosts: Arc>, dns_resolver: Arc, - ) -> Result<(), crate::error::Error> { + ) -> Result<(), crate::error::Error> + where + S: TcpStreamTrait + TcpConnect, + { let worker_id = task::try_id() .map(|id| id.to_string()) .unwrap_or("?".to_string()); @@ -199,18 +217,21 @@ impl Worker { /// Handles a single email transaction for a single recipient domain. #[expect(clippy::too_many_arguments)] - async fn handle_single_domain( + async fn handle_single_domain( tls_resumption_store: Arc, - smtp_connection_pool: Arc, + smtp_connection_pool: Arc>, mxdeliv_unsupported_hosts: Arc>, https_client: HttpsClient, dns_resolver: Arc, domain: AddressDomain, envelope: Envelope, client_hostname: String, - ) -> Result { + ) -> Result + where + S: TcpStreamTrait + TcpConnect, + { let mut allow_invalid_cert = false; - let mut skip_tls = false; // only respected by smtp channel + let mut skip_tls = SMTP_SKIP_TLS; // only respected by smtp channel let mx_hosts = match domain { // no-DNS setup; assume the ip from email address is the destination. @@ -316,12 +337,16 @@ impl Worker { } // SMTP channel (fallback) + let client_config = crate::smtp_client::ClientConfig { + client_hostname: &client_hostname, + tls_config: tls_config.clone(), + lmtp: false, + }; match crate::smtp_client::send( &mx_host, - 25, + SMTP_PORT, &envelope, - &client_hostname, - tls_config.clone(), + client_config, dns_resolver.clone(), smtp_connection_pool.clone(), ) From db86cfc6edbb743c73e70c57c8270c6efd70a05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Fri, 26 Jun 2026 10:32:35 +0200 Subject: [PATCH 147/166] refactor: Implement Display for AddressDomain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/src/transport.rs | 5 +---- filtermail/src/transport/worker.rs | 13 +++-------- filtermail/src/utils.rs | 35 ++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs index 1a9e1678..0a3997c1 100644 --- a/filtermail/src/transport.rs +++ b/filtermail/src/transport.rs @@ -68,10 +68,7 @@ where return Ok(()); } - log::trace!( - "Trying to acquire a permit for {} worker...", - domain.as_ref() - ); + log::trace!("Trying to acquire a permit for {domain} worker...",); if let Some(permit) = self.workers.get_permit(&domain) { transaction.state.permits.insert(domain, permit); } diff --git a/filtermail/src/transport/worker.rs b/filtermail/src/transport/worker.rs index 3472f062..0924589c 100644 --- a/filtermail/src/transport/worker.rs +++ b/filtermail/src/transport/worker.rs @@ -100,10 +100,7 @@ where if let Some(w) = &worker && w.handle.is_finished() { - log::error!( - "Worker for destination {} crashed! Restarting...", - destination.as_ref() - ); + log::error!("Worker for destination {destination} crashed! Restarting...",); worker = None; { let mut map = self.inner.write(); @@ -180,10 +177,7 @@ impl Worker { .map(|id| id.to_string()) .unwrap_or("?".to_string()); - log::info!( - "Starting worker {worker_id} for destination {}", - destination.as_ref() - ); + log::info!("Starting worker {worker_id} for destination {destination}"); let tls_resumption_store = Arc::new(rustls::client::ClientSessionMemoryCache::new(256)); let https_client = HttpsClient::new(tls_resumption_store.clone())?; @@ -206,8 +200,7 @@ impl Worker { .await; if message.response_tx.send(result).is_err() { log::error!( - "Worker {worker_id} ({}) failed to send response to transport handler.", - destination.as_ref() + "Worker {worker_id} ({destination}) failed to send response to transport handler." ); }; } diff --git a/filtermail/src/utils.rs b/filtermail/src/utils.rs index df0b0231..2139cabe 100644 --- a/filtermail/src/utils.rs +++ b/filtermail/src/utils.rs @@ -1,5 +1,6 @@ use hickory_resolver::{TokioResolver, proto::dnssec::TrustAnchors}; use mailparse::MailAddr; +use std::fmt::{Display, Formatter}; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; @@ -88,6 +89,24 @@ impl AsRef for AddressDomain { } } +impl Display for AddressDomain { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + AddressDomain::Literal(addr) => { + // naive check, but should be enough as long as `AddressDomain` + // is constructed using parse/from_str. + write!(f, "[")?; + if addr.contains(':') { + write!(f, "IPv6:")?; + } + write!(f, "{addr}]")?; + } + AddressDomain::Name(domain) => write!(f, "{domain}")?, + }; + Ok(()) + } +} + /// Logs email to `/tmp/filtermail-rejected//.eml` /// and returns the file path. /// @@ -160,4 +179,20 @@ mod tests { let result = AddressDomain::from_str(input).ok(); assert_eq!(result, expected); } + + #[rstest] + #[case::domain(AddressDomain::Name("example.org".to_string()), "example.org")] + #[case::ipv4(AddressDomain::Literal("192.0.2.0".to_string()), "192.0.2.0")] + #[case::ipv6(AddressDomain::Literal("2001:db8::1".to_string()), "2001:db8::1")] + fn test_address_domain_as_ref(#[case] input: AddressDomain, #[case] expected: &str) { + assert_eq!(input.as_ref(), expected); + } + + #[rstest] + #[case::domain(AddressDomain::Name("example.org".to_string()), "example.org")] + #[case::ipv4(AddressDomain::Literal("192.0.2.0".to_string()), "[192.0.2.0]")] + #[case::ipv6(AddressDomain::Literal("2001:db8::1".to_string()), "[IPv6:2001:db8::1]")] + fn test_address_domain_display(#[case] input: AddressDomain, #[case] expected: &str) { + assert_eq!(&format!("{input}"), expected); + } } From 03ca4a010b54c108fbe65fbec0e50ce249767db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Fri, 26 Jun 2026 11:38:25 +0200 Subject: [PATCH 148/166] docs(readme): Update Transport mode doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add information about workers, and synchronization of deliveries. Signed-off-by: Jagoda Ślązak --- filtermail/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/filtermail/README.md b/filtermail/README.md index b8759fd2..dff94713 100644 --- a/filtermail/README.md +++ b/filtermail/README.md @@ -110,10 +110,20 @@ As opposed to incoming/outgoing, it accepts connections from postfix over LMTP i to allow returning per-recipient status back to postfix. Received message is split per-domain and sent to recipients' MX servers over HTTP and SMTP, enforcing TLS. + As opposed to postfix, IPv4 and IPv6 connections are tried in parallel and first successful connection is used. HTTP delivery channel is preferred, and SMTP is used only if HTTP delivery fails. +Filtermail spawns a separate worker for each destination +(distinguished by [domain][RFC5322_3_4_1] part of the recipient's [addr-spec][RFC5322_3_4_1], +NOT the actual MX server). +Only messages to the same destination are guaranteed to be sent in-order +(if not deferred and sent over the same LMTP connection); +messages to different destinations are NOT synchronized. + +[RFC5322_3_4_1]: https://datatracker.ietf.org/doc/html/rfc5322#section-3.4.1 + ## Configuration ### chatmail.ini From ac9df36801d9b247c89b789f82aa6fb6dabccd39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:04:42 +0900 Subject: [PATCH 149/166] chore(release): prepare for 0.7.2 (#184) --- filtermail/CHANGELOG.md | 18 ++++++++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index fe850828..dda61448 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,21 @@ +## 0.7.2 - 2026-06-26 + +### Documentation + +- *(readme)* Disable colors in mermaid diagrams (#180) +- *(readme)* Update Transport mode doc + +### Features + +- *(transport)* Destination worker pool + +### Refactor + +- Implement Display for AddressDomain + +### Testing + +- Test filtermail-transport ## 0.7.1 - 2026-06-09 ### Bug Fixes diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 27bec1f5..41964162 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -565,7 +565,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.7.1" +version = "0.7.2" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 8d425e25..1b2a2c53 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.7.1" +version = "0.7.2" edition = "2024" license = "MIT" readme = "README.md" From d5ac67ecff78a77f8086782377134c7e579084ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:54:38 +0900 Subject: [PATCH 150/166] feat(transport): Worker eviction (#185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds max worker capacity of 500 to the worker pool, and an automatic shutdown of idle workers. Messages that would cause the capacity to be exceeded, are deferred. Additionally, ensures that the same worker is not spawned by two tasks at the same time. Signed-off-by: Jagoda Ślązak --- filtermail/src/smtp_responses.rs | 2 +- ...ransport__tests__smtp_send_mail_defer.snap | 2 +- ...s__smtp_send_mail_worker_cap_exceeded.snap | 51 ++++++++ filtermail/src/transport.rs | 91 ++++++++++++--- filtermail/src/transport/worker.rs | 110 +++++++++++++----- 5 files changed, 209 insertions(+), 47 deletions(-) create mode 100644 filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_worker_cap_exceeded.snap diff --git a/filtermail/src/smtp_responses.rs b/filtermail/src/smtp_responses.rs index e12fba2a..b290e1a7 100644 --- a/filtermail/src/smtp_responses.rs +++ b/filtermail/src/smtp_responses.rs @@ -4,4 +4,4 @@ pub const OK_SMTP_250: &str = "250 OK (SMTP)"; pub const ENCRYPTION_NEEDED_523: &str = "523 Encryption Needed: Invalid Unencrypted Mail"; pub const LOCAL_ERROR_451: &str = "451 Local error"; -pub const WORKER_BUSY_421: &str = "421 Worker for this destination is busy"; +pub const TRANSPORT_BUSY_421: &str = "421 Transport busy"; diff --git a/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_defer.snap b/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_defer.snap index 9dd43b2f..01244c79 100644 --- a/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_defer.snap +++ b/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_defer.snap @@ -20,7 +20,7 @@ TRANSACTION 1 > message > . < 250 OK (SMTP) -421 Worker for this destination is busy +421 Transport busy [filtermail-transport -> destination A] < 220 filtermail SMTP diff --git a/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_worker_cap_exceeded.snap b/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_worker_cap_exceeded.snap new file mode 100644 index 00000000..4c37501d --- /dev/null +++ b/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_worker_cap_exceeded.snap @@ -0,0 +1,51 @@ +--- +source: src/transport.rs +expression: "format!(\"TRANSACTION 1\\r\\n\\\n [postfix -> filtermail-transport]\\r\\n{record_postfix_1}\\r\\n\\\n [filtermail-transport -> destination A]\\r\\n{record_filtermail_1}\\r\\n\\r\\n\\\n TRANSACTION 2\\r\\n\\\n [postfix -> filtermail-transport]\\r\\n{record_postfix_2}\")" +--- +TRANSACTION 1 +[postfix -> filtermail-transport] +< 220 filtermail SMTP +> LHLO postfix +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +> . +< 250 OK (SMTP) + +[filtermail-transport -> destination A] +< 220 filtermail SMTP +> EHLO example.org +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +. +< 250 OK + + +TRANSACTION 2 +[postfix -> filtermail-transport] +< 220 filtermail SMTP +> LHLO postfix +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 421 Transport busy diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs index 0a3997c1..cd79b8a3 100644 --- a/filtermail/src/transport.rs +++ b/filtermail/src/transport.rs @@ -2,7 +2,7 @@ mod https_client; mod worker; use crate::config::Config; -use crate::smtp_responses::{LOCAL_ERROR_451, WORKER_BUSY_421}; +use crate::smtp_responses::{LOCAL_ERROR_451, TRANSPORT_BUSY_421}; use crate::smtp_server::{SmtpHandler, Transaction}; use crate::tcp::{TcpConnect, TcpStreamTrait}; use crate::utils::AddressDomain; @@ -32,12 +32,17 @@ where Ok(Self { workers }) } - /// Same as [`Self::new`], but lets you set worker queue size. + /// Same as [`Self::new`], but lets you set worker queue size and pool capacity. /// /// Only used for tests. #[cfg(test)] - pub fn with_queue_size(config: Config, queue_size: usize) -> Result { - let workers = WorkerPool::with_queue_size(config, queue_size)?; + pub fn with_queue_size_and_pool_capacity( + config: Config, + queue_size: usize, + pool_capacity: usize, + ) -> Result { + let workers = + WorkerPool::with_queue_size_and_pool_capacity(config, queue_size, pool_capacity)?; Ok(Self { workers }) } @@ -94,7 +99,7 @@ where // deferred mails to be constantly retried. if transaction.state.permits.is_empty() { - return Err(WORKER_BUSY_421.to_string()); + return Err(TRANSPORT_BUSY_421.to_string()); } Ok(()) @@ -133,11 +138,10 @@ where if let Some(permit) = transaction.state.permits.remove(rcpt_domain) { let (message, receiver) = WorkerMessage::new(domain_envelope); permit.send(message); - // todo: receiver timeout? transactions.spawn(receiver).id() } else { transactions - .spawn(async move { Ok(Err(WORKER_BUSY_421.to_string())) }) + .spawn(async move { Ok(Err(TRANSPORT_BUSY_421.to_string())) }) .id() }; task_id_domain_map.insert(receiver_task_id, rcpt_domain); @@ -152,11 +156,14 @@ where let smtp_response = match result { Ok((_, Ok(Ok(resp)))) | Ok((_, Ok(Err(resp)))) => resp, - Ok((_, Err(e))) => { + Ok((_, Err(_))) => { log::error!( - "Worker task failed while delivering to {}: {e}", + "Delivery to {} failed due to a dead worker!", domain.map(AsRef::as_ref).unwrap_or("") ); + if let Some(domain) = domain { + self.workers.cleanup(domain); + } LOCAL_ERROR_451.to_string() } Err(e) => { @@ -243,9 +250,16 @@ mod tests { /// Spawns filtermail-transport. /// /// Returns a pointer to the underlying handler. - fn spawn_filtermail_transport() -> TestResult>> { + fn spawn_filtermail_transport( + queue_size: usize, + pool_capacity: usize, + ) -> TestResult>> { let config = Config::default(); - let transport = Arc::new(TransportHandler::with_queue_size(config.clone(), 1)?); + let transport = Arc::new(TransportHandler::with_queue_size_and_pool_capacity( + config.clone(), + queue_size, + pool_capacity, + )?); tokio::spawn(run_smtp_server( &FILTERMAIL_ADDR, transport.clone(), @@ -302,8 +316,11 @@ mod tests { #[rstest] #[tokio::test] async fn test_rcpt_to_and_start_data(addrs1: Vec, addrs2: Vec) -> TestResult { - let transport_handler = - TransportHandler::::with_queue_size(Config::default(), 1)?; + let transport_handler = TransportHandler::::with_queue_size_and_pool_capacity( + Config::default(), + 1, + 500, + )?; let domain1 = AddressDomain::from_str(addrs1.first().unwrap())?; let domain2 = AddressDomain::from_str(addrs2.first().unwrap())?; @@ -358,7 +375,7 @@ mod tests { #[tokio::test] async fn test_smtp_send_mail() -> TestResult { let mut remote_mta = spawn_mock_mta()?; - spawn_filtermail_transport()?; + spawn_filtermail_transport(1, 2)?; let envelope = Envelope { mail_from: "sender@here".to_string(), @@ -398,7 +415,7 @@ mod tests { #[tokio::test] async fn test_smtp_send_mail_defer() -> TestResult { let mut remote_mta = spawn_mock_mta()?; - let transport = spawn_filtermail_transport()?; + let transport = spawn_filtermail_transport(1, 500)?; let mut envelope = Envelope { mail_from: "sender@here".to_string(), @@ -438,4 +455,48 @@ mod tests { Ok(()) } + + #[rstest] + #[serial] + #[tokio::test] + async fn test_smtp_send_mail_worker_cap_exceeded() -> TestResult { + let mut remote_mta = spawn_mock_mta()?; + spawn_filtermail_transport(30, 1)?; + + let envelope_1 = Envelope { + mail_from: "sender@here".to_string(), + rcpt_to: vec!["a1@localhost".to_string()], + data: "message\r\n".as_bytes().to_vec(), + }; + + let envelope_2 = Envelope { + mail_from: "sender@here".to_string(), + rcpt_to: vec!["b1@[127.0.0.1]".to_string()], + data: "message\r\n".as_bytes().to_vec(), + }; + + // 1 + let (record_postfix_1, record_filtermail_1) = { + let record_postfix = lmtp_send(&envelope_1).await?; + let record_filtermail = remote_mta.recv().await.unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + assert!(remote_mta.is_empty()); + (record_postfix, record_filtermail) + }; + + // 2 + let record_postfix_2 = lmtp_send(&envelope_2).await?; + tokio::time::sleep(Duration::from_secs(1)).await; + assert!(remote_mta.is_empty()); + + insta::assert_snapshot!(format!( + "TRANSACTION 1\r\n\ + [postfix -> filtermail-transport]\r\n{record_postfix_1}\r\n\ + [filtermail-transport -> destination A]\r\n{record_filtermail_1}\r\n\r\n\ + TRANSACTION 2\r\n\ + [postfix -> filtermail-transport]\r\n{record_postfix_2}" + )); + + Ok(()) + } } diff --git a/filtermail/src/transport/worker.rs b/filtermail/src/transport/worker.rs index 0924589c..36c670e7 100644 --- a/filtermail/src/transport/worker.rs +++ b/filtermail/src/transport/worker.rs @@ -17,6 +17,7 @@ use tokio::sync::mpsc::OwnedPermit; use tokio::sync::{mpsc, oneshot}; use tokio::task; use tokio::task::JoinHandle; +use tokio::time::timeout; use tokio_rustls::rustls; #[cfg(not(test))] @@ -35,16 +36,25 @@ const SMTP_SKIP_TLS: bool = true; /// all new messages will be immediately deferred. const PER_DESTINATION_QUEUE_SIZE: usize = 30; +/// Max number of [`Worker`]s operating at the same time. +const MAX_WORKERS: usize = 500; + +/// How long a worker can stay idle. +/// +/// Exceeding this value will cause a worker shutdown. +const WORKER_KEEPALIVE_DURATION: Duration = Duration::from_secs(60); + type SMTPResponse = Result; pub struct WorkerPool { - inner: RwLock>>, + inner: Arc>>>, client_hostname: String, smtp_connection_pool: Arc>, mxdeliv_unsupported_hosts: Arc>, monitor_handle: JoinHandle<()>, dns_resolver: Arc, queue_size: usize, + capacity: usize, } impl WorkerPool @@ -72,48 +82,61 @@ where mxdeliv_unsupported_hosts: mxdeliv_cache, monitor_handle, queue_size: PER_DESTINATION_QUEUE_SIZE, + capacity: MAX_WORKERS, }) } - /// Same as [`Self::new`], but lets you set the size of the queue. + /// Same as [`Self::new`], but lets you set the size of the queue and pool capacity. /// /// Used only for tests. #[cfg(test)] - pub fn with_queue_size(config: Config, queue_size: usize) -> Result { + pub fn with_queue_size_and_pool_capacity( + config: Config, + queue_size: usize, + pool_capacity: usize, + ) -> Result { let mut this = Self::new(config)?; this.queue_size = queue_size; + this.capacity = pool_capacity; Ok(this) } - fn get_or_create_worker(&self, destination: &AddressDomain) -> Arc { + /// Gets a worker for provided `destination`, + /// spawning a new one if required. + /// + /// Returns [`None`], if operation requires spawning a new worker, + /// but pool already operates at maximum worker capacity. + fn get_or_create_worker(&self, destination: &AddressDomain) -> Option> { // NOTE: these locks are blocking, but critical section here is quite small and // shouldn't cause issues in async code. // NOTE: read() returns a guard that is dropped before the match statement. // This must be ensured or else, the write() line would cause a deadlock. let worker = { - let mut worker = { - let map = self.inner.read(); - map.get(destination).cloned() - }; - // Remove (and re-create) worker if it finished/crashed. - // In reality, this should never happen. - if let Some(w) = &worker - && w.handle.is_finished() - { - log::error!("Worker for destination {destination} crashed! Restarting...",); - worker = None; - { - let mut map = self.inner.write(); - map.remove(destination); - } - }; - worker + let map = self.inner.read(); + map.get(destination).cloned() }; match worker { - Some(worker) => worker, + Some(worker) => Some(worker), None => { // Worker for this destination wasn't spawned yet. + let mut map = self.inner.write(); + + // we check if it wasn't spawned in the meantime first + if let Some(worker) = map.get(destination) + && !worker.handle.is_finished() + { + return Some(worker.clone()); + } + + if map.len() >= self.capacity { + log::warn!( + "Worker pool operating at maximum capacity! \ + Messages to new destinations will be deferred." + ); + return None; + } + let (tx, rx) = mpsc::channel(self.queue_size); let handle = tokio::spawn(Worker::run( destination.clone(), @@ -122,24 +145,47 @@ where self.smtp_connection_pool.clone(), self.mxdeliv_unsupported_hosts.clone(), self.dns_resolver.clone(), + self.inner.clone(), )); log::trace!("Worker {} spawned", handle.id()); let worker = Arc::new(Worker { tx, handle }); - self.inner - .write() - .insert(destination.clone(), worker.clone()); - worker + map.insert(destination.clone(), worker.clone()); + Some(worker) } } } /// Tries to get an [`OwnedPermit`] to the worker for specified destination. /// - /// Returns [`None`] if the worker's queue is full. + /// Returns [`None`] if: + /// + /// - the worker's queue is full, + /// - operation requires spawning a new worker, + /// but pool already operates at maximum worker capacity. pub fn get_permit(&self, destination: &AddressDomain) -> Option> { - let worker = self.get_or_create_worker(destination); - worker.tx.clone().try_reserve_owned().ok() + if let Some(worker) = self.get_or_create_worker(destination) { + return worker.tx.clone().try_reserve_owned().ok(); + } + None + } + + /// Informs pool that a worker has exited unexpectedly. + /// + /// Such worker will be removed from the pool, + /// and re-created on the next [`Self::get_permit`] call. + pub fn cleanup(&self, destination: &AddressDomain) { + let mut map = self.inner.write(); + // we first check in case other task already removed/re-created it + if let Some(worker) = map.get(destination) + && worker.handle.is_finished() + { + log::info!( + "Removing a dead worker {} for destination {destination}", + worker.handle.id() + ); + map.remove(destination); + } } } @@ -169,6 +215,7 @@ impl Worker { smtp_connection_pool: Arc>, mxdeliv_unsupported_hosts: Arc>, dns_resolver: Arc, + worker_pool: Arc>>>, ) -> Result<(), crate::error::Error> where S: TcpStreamTrait + TcpConnect, @@ -182,7 +229,7 @@ impl Worker { let tls_resumption_store = Arc::new(rustls::client::ClientSessionMemoryCache::new(256)); let https_client = HttpsClient::new(tls_resumption_store.clone())?; - while let Some(message) = rx.recv().await { + while let Ok(Some(message)) = timeout(WORKER_KEEPALIVE_DURATION, rx.recv()).await { log::trace!( "Worker {worker_id} received a message from {}", message.envelope.mail_from @@ -205,6 +252,9 @@ impl Worker { }; } + log::info!("Worker {worker_id} for domain {destination} shutting down..."); + worker_pool.write().remove(&destination); + Ok(()) } From 222bf3671252833850ae42c773245aeae21f3bc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:13:53 +0900 Subject: [PATCH 151/166] chore(release): prepare for 0.7.3 (#189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 5 +++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index dda61448..00007646 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.7.3 - 2026-06-27 + +### Features + +- *(transport)* Worker eviction (#185) ## 0.7.2 - 2026-06-26 ### Documentation diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 41964162..7af2d45c 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -565,7 +565,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.7.2" +version = "0.7.3" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 1b2a2c53..aeb44d4c 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.7.2" +version = "0.7.3" edition = "2024" license = "MIT" readme = "README.md" From 4d7bcf51b7a5dd7b59626a58de25085910e3184f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:05:21 +0900 Subject: [PATCH 152/166] chore: update filtermail.mtail (#190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs for filtermail-transport changed in v0.7.2+. Signed-off-by: Jagoda Ślązak --- filtermail/contrib/filtermail.mtail | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/contrib/filtermail.mtail b/filtermail/contrib/filtermail.mtail index bc72c22f..a37f6218 100644 --- a/filtermail/contrib/filtermail.mtail +++ b/filtermail/contrib/filtermail.mtail @@ -30,7 +30,7 @@ counter filtermail_smtp_error_total by reason # filtermail::transport # -/filtermail\[\d+\]: \[WARN\s+filtermail::transport\] Connection error relaying to mail server \S+: / { +/filtermail\[\d+\]: \[WARN\s+filtermail::transport::worker\] Connection error relaying to mail server \S+: / { /timed out/ { filtermail_transport_error_total["connection_timeout"]++ } otherwise { @@ -52,7 +52,7 @@ counter filtermail_smtp_error_total by reason filtermail_transport_error_total["other"]++ }}}}}}} -/filtermail\[\d+\]: \[WARN\s+filtermail::transport\] Unexpected error while delivering/ { +/filtermail\[\d+\]: \[WARN\s+filtermail::transport::worker\] Unexpected error while delivering/ { filtermail_transport_unexpected_total++ } From 0fce0a21913b2410646218fd198ff099cc797447 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:10:18 +0200 Subject: [PATCH 153/166] chore(deps): Bump actions/checkout from 6.0.3 to 7.0.0 (#186) Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.3 to 7.0.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 8 ++++---- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 729b52e9..08f0bec5 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: name: Check Formatting runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master @@ -37,7 +37,7 @@ jobs: name: Check Unused Dependencies runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: bnjbvr/cargo-machete@ac30a525c0a8d163a92d727b3ff079ee3f6ecb08 # v0.9.2 @@ -51,7 +51,7 @@ jobs: toolchain: ["stable", "beta"] continue-on-error: ${{ matrix.toolchain == 'beta' }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master @@ -67,7 +67,7 @@ jobs: name: Run rust tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index e2ba13de..e3b8e8a7 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -7,7 +7,7 @@ jobs: name: Build binaries. runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master From 7f14693b31fa8f0abab411342865e48728ff0cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:00:13 +0900 Subject: [PATCH 154/166] feat(logs): Log incoming mailer-daemon message sources (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs mailer-daemon messages separately from other unencrypted mails. Signed-off-by: Jagoda Ślązak --- filtermail/src/inbound.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index d0ea23ed..7272dc6f 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -136,15 +136,16 @@ where return self.verify_origin(&transaction.envelope, &from_addr).await; } - log::info!("Incoming: Filtering unencrypted mail."); - // Allow cleartext mailer-daemon messages if let Some(auto_submitted) = message.headers.get_first_value("Auto-Submitted") && !auto_submitted.is_empty() && from_addr.to_lowercase().starts_with("mailer-daemon@") && message.ctype.mimetype == "multipart/report" { + log::info!("Incoming: Filtering mailer-daemon message from <{from_addr}>"); return self.verify_origin(&transaction.envelope, &from_addr).await; + } else { + log::info!("Incoming: Filtering unencrypted mail."); } for recipient in &transaction.envelope.rcpt_to { From d00d2b870b58db3a3eb2787c56cd4c8eddd314db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:01:23 +0200 Subject: [PATCH 155/166] chore(deps): Bump taiki-e/install-action from 2.81.10 to 2.82.2 (#187) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.81.10 to 2.82.2. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/7a79fe8c3a13344501c80d99cae481c1c9085912...9e1e5806d4a4822de933115878265be9aaa786d9) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.82.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 08f0bec5..e11d9151 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - - uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2 + - uses: taiki-e/install-action@9e1e5806d4a4822de933115878265be9aaa786d9 # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index e3b8e8a7..9b31153e 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2 + - uses: taiki-e/install-action@9e1e5806d4a4822de933115878265be9aaa786d9 # v2 with: tool: cargo-zigbuild - name: Build From 5cdae22d310049f0880403a248268132b9687b6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:48:43 +0200 Subject: [PATCH 156/166] chore(deps): Bump dtolnay/rust-toolchain (#188) Bumps [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from 3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 to fa04a1451ff1842e2626ccb99004d0195b455a88. - [Release notes](https://github.com/dtolnay/rust-toolchain/releases) - [Commits](https://github.com/dtolnay/rust-toolchain/compare/3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9...fa04a1451ff1842e2626ccb99004d0195b455a88) --- updated-dependencies: - dependency-name: dtolnay/rust-toolchain dependency-version: 67ef31d5b988238dd797d409d6f9574278e20537 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 6 +++--- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index e11d9151..1fe8c3cd 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master with: toolchain: nightly components: rustfmt @@ -54,7 +54,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master with: toolchain: ${{ matrix.toolchain }} components: clippy @@ -70,7 +70,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master with: toolchain: stable - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index 9b31153e..22eaeb35 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master with: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl From ed5dfbe0ff758f117b041e2791143dc6a44367af Mon Sep 17 00:00:00 2001 From: l Date: Wed, 1 Jul 2026 12:51:37 +0000 Subject: [PATCH 157/166] test: place #[tokio::test] after rstest case macros (#192) Otherwise only the first case is built. --- filtermail/src/dkim_verifier.rs | 12 +++++++----- filtermail/src/inbound.rs | 6 ++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/filtermail/src/dkim_verifier.rs b/filtermail/src/dkim_verifier.rs index 6b8a42aa..8872ed35 100644 --- a/filtermail/src/dkim_verifier.rs +++ b/filtermail/src/dkim_verifier.rs @@ -271,18 +271,20 @@ mod tests { use rstest::rstest; #[rstest] - #[tokio::test] #[case::simple_simple_canonicalization( r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5krC4Xi5Wkr6eMlla38LCFmV645E3FLAgsRl2YJ0SrZ4N2Vw1/yH0mefvtk7HYE7ytV7RQl/er2CkSsaHLJSYLmPCBw5CO6PSsBSXuh6DBqdylh/1t9vVQ9p38fTwn9gU1QvplcpRQL9eepRra1k24VMIaVy2ZZcu3LI9zkPsR7o7TyNaeMhsL8ouWInWc1NSid+p0SgliQuwHIejZhlTPE60JLbJE0OR9I4wmq3377H6z/QrO8XeabCgtmTuzE/hTRyIyNS40jql/99pjlhIcjM2U+P2B0FjwYt7BwLHsgANr74ctlnKY+SdH25rNwVpPmkotaULG5SJCByKBkfCwIDAQAB;s=email;t=s"#, - include_bytes!("../test_data/dkim-abjadiyah.eml") + include_bytes!("../test_data/dkim-abjadiyah.eml"), + "abjadiyah.xyz" )] #[case::txt_escaped_quotes( r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1giTh8KDkEchWhrAB6hGnb+V87kTezkt5I3SP7BGNg8wpv0yAuj/SUmnsttYmcEU+zmNAPqxePmCNvmjLYi/c3YyWEBwHcLyZE9OlS9W4enPdsoCuEN3DayzN4JCV3MsXMedCORvLFXmIARDXDLJUSJeqCeQoudXa9GmF1CrCmx70YyTtV0xOIxEzo7z0DkUL9" "7vGmNJCv6EMpi9wccMKKu8NSmOv+DBw1MLIJqChSZMCs8CYZ5i0KT/+Lijtn6B7wyOcAuQsVL+zr7DWYrFdrePe0wGuivfJ3SvUEfUo1SIykl0nvm0iLGhjNmNa1e/tUw4ULXhQ12Qw685+sq7wIDAQAB;s=email;t=s"#, - include_bytes!("../test_data/dkim-privitty.eml") + include_bytes!("../test_data/dkim-privitty.eml"), + "chat.privittytech.com" )] - async fn test_dkim_verifier(#[case] txt: &str, #[case] message: &[u8]) { + #[tokio::test] + async fn test_dkim_verifier(#[case] txt: &str, #[case] message: &[u8], #[case] domain: &str) { let verifier = DkimVerifier::mock(txt.to_string()); - verifier.verify(message, "abjadiyah.xyz").await.unwrap(); + verifier.verify(message, domain).await.unwrap(); } #[rstest] diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 7272dc6f..c0e24b52 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -198,9 +198,11 @@ mod tests { /// Test that domain-literals are not rejected by origin check. #[rstest] - #[tokio::test] #[case::ipv4(include_bytes!("../test_data/encrypted-ipv4.eml"), "one@[192.0.2.0]")] - #[case::ipv6(include_bytes!("../test_data/encrypted-ipv6.eml"), "one@[IPv6:2001:db8::1]")] + // Waiting for a release of mailparse with the fix https://github.com/staktrace/mailparse/pull/138 + // for the issue https://github.com/staktrace/mailparse/issues/137 to be released. + //#[case::ipv6(include_bytes!("../test_data/encrypted-ipv6.eml"), "one@[IPv6:2001:db8::1]")] + #[tokio::test] async fn test_domain_literals_allowed( #[case] eml: &[u8], #[case] address: &str, From 5717940ac135a2cc4ca33b0712612363b1029677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:15:41 +0900 Subject: [PATCH 158/166] chore(release): prepare for 0.7.4 (#193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jagoda Ślązak --- filtermail/CHANGELOG.md | 13 +++++++++++++ filtermail/Cargo.lock | 2 +- filtermail/Cargo.toml | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md index 00007646..5904fd05 100644 --- a/filtermail/CHANGELOG.md +++ b/filtermail/CHANGELOG.md @@ -1,3 +1,16 @@ +## 0.7.4 - 2026-07-01 + +### Features + +- *(logs)* Log incoming mailer-daemon message sources (#191) + +### Miscellaneous Tasks + +- Update filtermail.mtail (#190) + +### Testing + +- Place #[tokio::test] after rstest case macros (#192) ## 0.7.3 - 2026-06-27 ### Features diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 7af2d45c..c09b058b 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -565,7 +565,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filtermail" -version = "0.7.3" +version = "0.7.4" dependencies = [ "async-trait", "base64", diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index aeb44d4c..2318673d 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filtermail" -version = "0.7.3" +version = "0.7.4" edition = "2024" license = "MIT" readme = "README.md" From 88e2124d5941b52d34954099be7ae2407268b28d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:29:20 +0200 Subject: [PATCH 159/166] chore(deps): Bump hyper from 1.9.0 to 1.10.1 (#197) Bumps [hyper](https://github.com/hyperium/hyper) from 1.9.0 to 1.10.1. - [Release notes](https://github.com/hyperium/hyper/releases) - [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md) - [Commits](https://github.com/hyperium/hyper/compare/v1.9.0...v1.10.1) --- updated-dependencies: - dependency-name: hyper dependency-version: 1.10.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index c09b058b..c693f663 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -799,9 +799,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -992,9 +992,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", From df6c9d777d35d7113bb5cbdc5365a1282db41260 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:02:06 +0900 Subject: [PATCH 160/166] chore(deps): Bump log from 0.4.30 to 0.4.33 (#194) --- filtermail/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index c693f663..43357f69 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -1372,9 +1372,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" From 2bba729b6b63dc1fcd93c95ff81d2c9d5d417602 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:02:50 +0900 Subject: [PATCH 161/166] chore(deps): Bump memchr from 2.8.1 to 2.8.2 (#195) --- filtermail/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 43357f69..0062e632 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -1407,9 +1407,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "mio" From 9613b2f14ff989af323e6150fc623d6ab37c095b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:04:06 +0900 Subject: [PATCH 162/166] chore(deps): Bump webpki-roots from 1.0.7 to 1.0.8 (#196) --- filtermail/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 0062e632..dc1e5463 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -2710,9 +2710,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] From 678bf2a68432825cd7daf5c7f25b0abbb3c8af68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:04:58 +0900 Subject: [PATCH 163/166] chore(deps): Bump taiki-e/install-action from 2.82.2 to 2.82.5 (#198) --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 1fe8c3cd..c87ee7f9 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - - uses: taiki-e/install-action@9e1e5806d4a4822de933115878265be9aaa786d9 # v2 + - uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index 22eaeb35..212c73ed 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@9e1e5806d4a4822de933115878265be9aaa786d9 # v2 + - uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2 with: tool: cargo-zigbuild - name: Build From e47740f45ffce8eee6848e56566d488ec97514a4 Mon Sep 17 00:00:00 2001 From: link2xt Date: Thu, 9 Jul 2026 12:21:50 +0000 Subject: [PATCH 164/166] refactor: do not evaluate smtp_write! argument twice --- filtermail/src/smtp_client.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/filtermail/src/smtp_client.rs b/filtermail/src/smtp_client.rs index 46048459..bff3d874 100644 --- a/filtermail/src/smtp_client.rs +++ b/filtermail/src/smtp_client.rs @@ -300,8 +300,10 @@ where macro_rules! smtp_write { ($command: expr) => { - log::trace!("Sending: {}", String::from_utf8_lossy($command)); - buf_stream.write_all($command).await?; + let command = $command; + let bytes: &[u8] = command.as_ref(); + log::trace!("Sending: {}", String::from_utf8_lossy(bytes)); + buf_stream.write_all(bytes).await?; buf_stream.flush().await?; }; } @@ -375,7 +377,7 @@ where smtp_read!("initial greeting", "220")?; smtp_cmd!( - format!("{greeting} {}\r\n", { config.client_hostname }).as_bytes(), + format!("{greeting} {}\r\n", { config.client_hostname }), greeting, "250" )?; @@ -421,7 +423,7 @@ where buf_stream = BufStream::new(smtp_stream); smtp_cmd!( - format!("EHLO {}\r\n", config.client_hostname).as_bytes(), + format!("EHLO {}\r\n", config.client_hostname), "EHLO after STARTTLS", "250" )?; @@ -429,14 +431,14 @@ where } // MAIL FROM - smtp_write!(format!("MAIL FROM:<{}>\r\n", envelope.mail_from).as_bytes()); + smtp_write!(format!("MAIL FROM:<{}>\r\n", envelope.mail_from)); if !pipelining { smtp_read!("MAIL FROM", "250")?; } // RCPT TO for rcpt in &envelope.rcpt_to { - smtp_write!(format!("RCPT TO:<{}>\r\n", rcpt).as_bytes()); + smtp_write!(format!("RCPT TO:<{}>\r\n", rcpt)); if !pipelining { smtp_read!("RCPT TO", "250")?; } From a0fe9c4b2c6b4266d062c7c3ee724487fd7bf472 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:34:50 +0200 Subject: [PATCH 165/166] chore(deps): Bump taiki-e/install-action from 2.82.5 to 2.83.1 (#202) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.82.5 to 2.83.1. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/bffeee26d4db9be238a4ea78d8826604ebcb594d...2ca9b94c269419b7b0c711c09d0b21c4e1d51145) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.83.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 2 +- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index c87ee7f9..3df2a550 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: toolchain: nightly components: rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - - uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2 + - uses: taiki-e/install-action@2ca9b94c269419b7b0c711c09d0b21c4e1d51145 # v2 with: tool: taplo-cli - run: taplo format --check && cargo fmt --check diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index 212c73ed..b28055a5 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - - uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2 + - uses: taiki-e/install-action@2ca9b94c269419b7b0c711c09d0b21c4e1d51145 # v2 with: tool: cargo-zigbuild - name: Build From 6b3d475c82f44bea040bfaa428101fc0b70c4b1c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:35:16 +0200 Subject: [PATCH 166/166] chore(deps): Bump dtolnay/rust-toolchain (#203) Bumps [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from fa04a1451ff1842e2626ccb99004d0195b455a88 to 2c7215f132e9ebf062739d9130488b56d53c060c. - [Release notes](https://github.com/dtolnay/rust-toolchain/releases) - [Commits](https://github.com/dtolnay/rust-toolchain/compare/fa04a1451ff1842e2626ccb99004d0195b455a88...2c7215f132e9ebf062739d9130488b56d53c060c) --- updated-dependencies: - dependency-name: dtolnay/rust-toolchain dependency-version: 2c7215f132e9ebf062739d9130488b56d53c060c dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- filtermail/.github/workflows/ci.yml | 6 +++--- filtermail/.github/workflows/release.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml index 3df2a550..605333bc 100644 --- a/filtermail/.github/workflows/ci.yml +++ b/filtermail/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master with: toolchain: nightly components: rustfmt @@ -54,7 +54,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master with: toolchain: ${{ matrix.toolchain }} components: clippy @@ -70,7 +70,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master with: toolchain: stable - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml index b28055a5..af1825df 100644 --- a/filtermail/.github/workflows/release.yml +++ b/filtermail/.github/workflows/release.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master with: toolchain: stable targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl