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