Merge branch 'master' into synth-relay
This commit is contained in:
commit
472145ee27
35 changed files with 1455 additions and 904 deletions
|
@ -1,7 +1,7 @@
|
|||
image: rust
|
||||
before_script:
|
||||
- apt-get --assume-yes update
|
||||
- apt-get --assume-yes install pkg-config libmilter-dev spamc miltertest
|
||||
- apt-get --assume-yes install spamc miltertest
|
||||
test:
|
||||
script:
|
||||
- cargo test --verbose
|
||||
|
|
107
CHANGELOG.md
107
CHANGELOG.md
|
@ -1,72 +1,95 @@
|
|||
# SpamAssassin Milter changelog
|
||||
|
||||
## 0.2.2 (unreleased)
|
||||
## 0.3.1 (unreleased)
|
||||
|
||||
* Add `--synth-relay` option to allow inserting the synthesised internal relay
|
||||
header (the MTA’s `Received` header) at a different position than at the
|
||||
very beginning.
|
||||
### Added
|
||||
|
||||
* Add `--synth-relay` option to allow inserting the synthesised internal relay
|
||||
header (the MTA’s `Received` header) at a different position than at the very
|
||||
beginning.
|
||||
|
||||
## 0.3.0 (2022-03-08)
|
||||
|
||||
In this release, the milter implementation has been replaced with the new
|
||||
[indymilter] library. With this change, there is no longer a dependency on the
|
||||
libmilter C library. SpamAssassin Milter is now a pure Rust application.
|
||||
|
||||
The minimum supported Rust version is now 1.56.1.
|
||||
|
||||
### Changed
|
||||
|
||||
* The minimum supported Rust version is now 1.56.1 (using Rust edition 2021).
|
||||
* The syntax of the mandatory `SOCKET` argument has changed:
|
||||
- Use <code>inet:<em>host</em>:<em>port</em></code> for a TCP socket.
|
||||
- Use <code>unix:<em>path</em></code> for a UNIX domain socket.
|
||||
* The command-line help information has changed its appearance slightly with the
|
||||
update of the underlying [clap] CLI library.
|
||||
* The changelog is now maintained in a more structured format, similar to
|
||||
https://keepachangelog.com.
|
||||
|
||||
[indymilter]: https://crates.io/crates/indymilter
|
||||
[clap]: https://crates.io/crates/clap
|
||||
|
||||
## 0.2.1 (2021-12-23)
|
||||
|
||||
* Various cosmetic improvements in code and tests, and updates to
|
||||
documentation.
|
||||
* Update dependencies.
|
||||
* Various cosmetic improvements in code and tests, and updates to documentation.
|
||||
* Update dependencies.
|
||||
|
||||
## 0.2.0 (2021-08-26)
|
||||
|
||||
* Bump minimum supported Rust version to 1.46.0.
|
||||
* (defaults change) Invoke `spamc` using the absolute path `/usr/bin/spamc`
|
||||
(instead of any executable named `spamc` in the search path). To customise
|
||||
this, set the environment variable `SPAMASSASSIN_MILTER_SPAMC` to the
|
||||
desired path when building the application.
|
||||
* Revise header rewriting logic. Handling and placement of `X-Spam-` headers
|
||||
now more accurately mirrors that applied by SpamAssassin.
|
||||
* Include authentication status in information passed on to SpamAssassin.
|
||||
* Update dependencies.
|
||||
* Bump minimum supported Rust version to 1.46.0.
|
||||
* (defaults change) Invoke `spamc` using the absolute path `/usr/bin/spamc`
|
||||
(instead of any executable named `spamc` in the search path). To customise
|
||||
this, set the environment variable `SPAMASSASSIN_MILTER_SPAMC` to the desired
|
||||
path when building the application.
|
||||
* Revise header rewriting logic. Handling and placement of `X-Spam-` headers now
|
||||
more accurately mirrors that applied by SpamAssassin.
|
||||
* Include authentication status in information passed on to SpamAssassin.
|
||||
* Update dependencies.
|
||||
|
||||
## 0.1.6 (2021-05-17)
|
||||
|
||||
* Improve processing of incoming `X-Spam-Flag` headers. Previously, in rare
|
||||
circumstances a message flagged as spam would not be rejected as requested.
|
||||
Reported by Petar Bogdanovic.
|
||||
* Update dependencies.
|
||||
* Improve processing of incoming `X-Spam-Flag` headers. Previously, in rare
|
||||
circumstances a message flagged as spam would not be rejected as requested.
|
||||
Reported by Petar Bogdanovic.
|
||||
* Update dependencies.
|
||||
|
||||
## 0.1.5 (2021-03-16)
|
||||
|
||||
* Read output from `spamc` in a separate thread in order to avoid blocking
|
||||
when processing large messages in certain configurations.
|
||||
* Document requirement to keep `--max-message-size` setting in sync with
|
||||
`spamc`’s `--max-size` setting.
|
||||
* Remove overly strict validation of command-line options.
|
||||
* Properly specify minimal dependency versions in `Cargo.toml`.
|
||||
* Document minimum supported Rust version 1.42.0.
|
||||
* Read output from `spamc` in a separate thread in order to avoid blocking when
|
||||
processing large messages in certain configurations.
|
||||
* Document requirement to keep `--max-message-size` setting in sync with
|
||||
`spamc`’s `--max-size` setting.
|
||||
* Remove overly strict validation of command-line options.
|
||||
* Properly specify minimal dependency versions in `Cargo.toml`.
|
||||
* Document minimum supported Rust version 1.42.0.
|
||||
|
||||
## 0.1.4 (2020-10-18)
|
||||
|
||||
* Correct a typo in log messages.
|
||||
* Isolate integration tests from any existing `spamc` configuration present on
|
||||
the host.
|
||||
* Various à la mode style improvements in code and project metadata.
|
||||
* Correct a typo in log messages.
|
||||
* Isolate integration tests from any existing `spamc` configuration present on
|
||||
the host.
|
||||
* Various à la mode style improvements in code and project metadata.
|
||||
|
||||
## 0.1.3 (2020-07-04)
|
||||
|
||||
* Add `--reply-code`, `--reply-status-code`, and `--reply-text` options to
|
||||
allow customising the SMTP reply when rejecting spam.
|
||||
* Log a warning and do not truncate the message body when `--max-message-size`
|
||||
is misconfigured (must be ≥ `spamc` max size as documented).
|
||||
* Update dependencies in `Cargo.lock`.
|
||||
* Add `--reply-code`, `--reply-status-code`, and `--reply-text` options to allow
|
||||
customising the SMTP reply when rejecting spam.
|
||||
* Log a warning and do not truncate the message body when `--max-message-size`
|
||||
is misconfigured (must be ≥ `spamc` max size as documented).
|
||||
* Update dependencies in `Cargo.lock`.
|
||||
|
||||
## 0.1.2 (2020-06-07)
|
||||
|
||||
* Bump milter dependency to version 0.2.1.
|
||||
* Remove existing UNIX domain socket at target path during startup.
|
||||
* Derive `Eq` and `PartialEq` for configuration structs.
|
||||
* Bump milter dependency to version 0.2.1.
|
||||
* Remove existing UNIX domain socket at target path during startup.
|
||||
* Derive `Eq` and `PartialEq` for configuration structs.
|
||||
|
||||
## 0.1.1 (2020-04-13)
|
||||
|
||||
* Use `Write::write_all` instead of `Write::write` in `spamc` client, in order
|
||||
to ensure buffers are written in their entirety.
|
||||
* Do not include `.gitignore` file in published crate.
|
||||
* Use `Write::write_all` instead of `Write::write` in `spamc` client, in order
|
||||
to ensure buffers are written in their entirety.
|
||||
* Do not include `.gitignore` file in published crate.
|
||||
|
||||
## 0.1.0 (2020-02-23)
|
||||
|
||||
|
|
414
Cargo.lock
generated
414
Cargo.lock
generated
|
@ -12,12 +12,14 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "ansi_term"
|
||||
version = "0.12.1"
|
||||
name = "async-trait"
|
||||
version = "0.1.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
|
||||
checksum = "061a7acccaa286c011ddc30970520b98fa40e00c9d644633fb26b5fc63a265e3"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -33,9 +35,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
|
||||
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
|
@ -43,6 +45,38 @@ version = "1.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "byte-strings"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "963ceed6e0041e1f4cdd9e2fae3b384f5613a22119b5bb04ccc14fc815e87ae3"
|
||||
dependencies = [
|
||||
"byte-strings-proc_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byte-strings-proc_macros"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e78e8673d97234c7a07636474b02c92fad06a0f26f70581aa46aee124c508e5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.19"
|
||||
|
@ -58,19 +92,114 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "2.34.0"
|
||||
version = "3.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
|
||||
checksum = "5177fac1ab67102d8989464efd043c6ff44191b1557ec1ddd489b4f7e1447e77"
|
||||
dependencies = [
|
||||
"ansi_term",
|
||||
"atty",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"os_str_bytes",
|
||||
"strsim",
|
||||
"termcolor",
|
||||
"textwrap",
|
||||
"unicode-width",
|
||||
"vec_map",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.1.19"
|
||||
|
@ -80,6 +209,29 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282a6247722caba404c065016bbfa522806e51714c34f5dfc3e4a3a46fcb4223"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indymilter"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8af90f5b5335bfceaf2fd7b85143f2ab9ff0f71fc062bbdb9a5ae432cc135368"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.3.1"
|
||||
|
@ -87,10 +239,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "68f2d64f2edebec4ce84ad108148e67e1064789bee435edc5b60ad398714a3a9"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.112"
|
||||
name = "lazy_static"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b03d17f364a3a042d5e5d46b053bbbf82c92c9430c592dd4c064dc6ee997125"
|
||||
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bf2e165bb3457c8e098ea76f3e3bc9db55f87aa90d52d0e6be741470916aaa4"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
|
@ -99,37 +266,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a"
|
||||
|
||||
[[package]]
|
||||
name = "milter"
|
||||
version = "0.2.4"
|
||||
name = "mio"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8303a6ac7b50962cb1a24ea9f35fd336dc680a628e167962adee4a9a1babf3"
|
||||
checksum = "ba272f85fa0b41fc91872be579b3bbe0f56b792aa361a380eb669469f68dafb2"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
"milter-callback",
|
||||
"milter-sys",
|
||||
"once_cell",
|
||||
"log",
|
||||
"miow",
|
||||
"ntapi",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "milter-callback"
|
||||
version = "0.2.4"
|
||||
name = "miow"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6288a38fe087aff33e732c50d4eb05a782a19eef0f29eaf1f9b931ba9ef37f1e"
|
||||
checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "milter-sys"
|
||||
version = "0.2.3"
|
||||
name = "ntapi"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46d0d54709e88c3b120d9c7bc90555bd5a1584c19f78ca15cd262d4de7faf34c"
|
||||
checksum = "c28774a7fd2fbb4f0babd8237ce554b73af68021b5f695a3cebd6c59bac0980f"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -151,6 +315,16 @@ dependencies = [
|
|||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.9.0"
|
||||
|
@ -158,25 +332,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "da32515d9f6e6e489d7bc9d84c71b060db7247dc035bbe44eac88cf87486d8d5"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.24"
|
||||
name = "os_str_bytes"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe"
|
||||
checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e280fbe77cc62c91527259e9442153f4688736748d24660126286329742b4c6c"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.34"
|
||||
version = "1.0.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f84e92c0f7c9d58328b85a78557813e4bd845130db68d7184635344399423b1"
|
||||
checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029"
|
||||
dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.10"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05"
|
||||
checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
@ -199,29 +388,81 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
|
||||
|
||||
[[package]]
|
||||
name = "spamassassin-milter"
|
||||
version = "0.2.1"
|
||||
name = "signal-hook"
|
||||
version = "0.3.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "647c97df271007dcea485bb74ffdb57f2e683f1306c854f468a0c244badabf2d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-tokio"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "213241f76fb1e37e27de3b6aa1b068a2c333233b59cca6634f634b80a27ecf1e"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"libc",
|
||||
"signal-hook",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9def91fd1e018fe007022791f865d0ccc9b3a0d5001e01aabb8b40e46000afb5"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spamassassin-milter"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"byte-strings",
|
||||
"chrono",
|
||||
"clap",
|
||||
"futures",
|
||||
"indymilter",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"milter",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"signal-hook",
|
||||
"signal-hook-tokio",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.82"
|
||||
version = "1.0.86"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8daf5dd0bb60cbd4137b1b587d2fc0ae729bc07cf01cd70b36a1ed5ade3b9d59"
|
||||
checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
@ -229,14 +470,20 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.11.0"
|
||||
name = "termcolor"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
|
||||
checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0066c8d12af8b5acd21e00547c3797fde4e8677254a7ee429176ccebbe93dd80"
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.1.44"
|
||||
|
@ -249,10 +496,54 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.9"
|
||||
name = "tokio"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973"
|
||||
checksum = "2af73ac49756f3f7c01172e34a23e5d0216f6c32333757c2c61feb2bbff5a5ee"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"memchr",
|
||||
"mio",
|
||||
"num_cpus",
|
||||
"once_cell",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b557f72f448c511a979e2564e55d74e6c4432fc96ff4f6241bc6bded342643b7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6c650a8ef0cd2dd93736f033d21cbd1224c5a967aa0c258d00fcf7dafef9b9f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"pin-project-lite",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03cfcb51380632a72d3111cb8d3447a8d908e577d31beeac006f836383d29a23"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
|
@ -260,12 +551,6 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
|
||||
|
||||
[[package]]
|
||||
name = "vec_map"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.10.0+wasi-snapshot-preview1"
|
||||
|
@ -288,6 +573,15 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
|
|
28
Cargo.toml
28
Cargo.toml
|
@ -1,8 +1,8 @@
|
|||
[package]
|
||||
name = "spamassassin-milter"
|
||||
version = "0.2.1"
|
||||
edition = "2018"
|
||||
rust-version = "1.46.0"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.56.1"
|
||||
description = "Milter for spam filtering with SpamAssassin"
|
||||
license = "GPL-3.0-or-later"
|
||||
categories = ["email"]
|
||||
|
@ -11,10 +11,18 @@ repository = "https://gitlab.com/glts/spamassassin-milter"
|
|||
exclude = ["/.gitignore", "/.gitlab-ci.yml"]
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.10"
|
||||
clap = "2.33"
|
||||
ipnet = "2.2"
|
||||
libc = "0.2.66"
|
||||
milter = "0.2.3"
|
||||
once_cell = "1.3"
|
||||
regex = "1.4"
|
||||
async-trait = "0.1.52"
|
||||
byte-strings = "0.2.2"
|
||||
chrono = "0.4.19"
|
||||
clap = "3.1.0"
|
||||
futures = "0.3.19"
|
||||
indymilter = "0.1.0"
|
||||
ipnet = "2.3.1"
|
||||
once_cell = "1.9.0"
|
||||
regex = "1.5.4"
|
||||
signal-hook = "0.3.13"
|
||||
signal-hook-tokio = { version = "0.3.0", features = ["futures-v0_3"] }
|
||||
tokio = { version = "1.15.0", features = ["fs", "io-util", "macros", "net", "process", "rt", "rt-multi-thread", "sync"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.15.0", features = ["signal", "time"] }
|
||||
|
|
26
README.md
26
README.md
|
@ -5,7 +5,7 @@ SpamAssassin server using the `spamc` client. It is a light-weight component
|
|||
that serves to integrate [Apache SpamAssassin] with a milter-capable MTA (mail
|
||||
server) such as [Postfix]. Its task is thus helping combat spam on email sites.
|
||||
|
||||
SpamAssassin Milter operates as a [milter] hooked into the MTA’s SMTP protocol
|
||||
SpamAssassin Milter operates as a milter hooked into the MTA’s SMTP protocol
|
||||
handler. It passes incoming messages to SpamAssassin for analysis, and then
|
||||
interprets the response from SpamAssassin and applies suggested changes to the
|
||||
message.
|
||||
|
@ -32,7 +32,6 @@ Postfix, and for delivery [Dovecot] with LMTP and the [Sieve plugin].
|
|||
|
||||
[Apache SpamAssassin]: https://spamassassin.apache.org
|
||||
[Postfix]: http://www.postfix.org
|
||||
[milter]: https://crates.io/crates/milter
|
||||
[spamass-milter]: https://savannah.nongnu.org/projects/spamass-milt
|
||||
[Dovecot]: https://dovecot.org
|
||||
[Sieve plugin]: https://doc.dovecot.org/configuration_manual/sieve
|
||||
|
@ -46,24 +45,12 @@ usual:
|
|||
cargo install --locked spamassassin-milter
|
||||
```
|
||||
|
||||
As a milter, this package requires the libmilter C library to be available. Be
|
||||
sure to install the libmilter shared library provided by your distribution.
|
||||
|
||||
The shared library is discovered using the pkg-config program. If your
|
||||
distribution does not install pkg-config metadata for libmilter, try using the
|
||||
provided `milter.pc` file. Put this file on the pkg-config path when running any
|
||||
Cargo command:
|
||||
|
||||
```
|
||||
PKG_CONFIG_PATH=/path/to/milter.pc cargo build
|
||||
```
|
||||
|
||||
SpamAssassin Milter uses the `spamc` program for communication with SpamAssassin
|
||||
server. By default, `/usr/bin/spamc` is used as the executable. To override
|
||||
this, set the environment variable `SPAMASSASSIN_MILTER_SPAMC` to the desired
|
||||
path when building the application.
|
||||
|
||||
The minimum supported Rust version is 1.46.0.
|
||||
The minimum supported Rust version is 1.56.1.
|
||||
|
||||
[Rust]: https://www.rust-lang.org
|
||||
|
||||
|
@ -83,14 +70,13 @@ use an up-to-date version of `miltertest`.)
|
|||
Once installed, SpamAssassin Milter can be invoked as `spamassassin-milter`.
|
||||
`spamassassin-milter` takes one mandatory argument, namely the listening socket
|
||||
of the milter (the socket to which the MTA will connect). The socket spec should
|
||||
be in one of the formats <code>inet:<em>port</em>@<em>host</em></code> or
|
||||
<code>inet6:<em>port</em>@<em>host</em></code> (for IPv6), or
|
||||
be in one of the formats <code>inet:<em>host</em>:<em>port</em></code> or
|
||||
<code>unix:<em>path</em></code>, for a TCP or UNIX domain socket, respectively.
|
||||
|
||||
For example, the following invocation starts SpamAssassin Milter on port 3000:
|
||||
|
||||
```
|
||||
spamassassin-milter inet:3000@localhost
|
||||
spamassassin-milter inet:localhost:3000
|
||||
```
|
||||
|
||||
The available options can be glimpsed by passing the `-h` flag:
|
||||
|
@ -126,7 +112,7 @@ going live. Combined with `--verbose`, this gives accurate insight into the
|
|||
changes that SpamAssassin Milter would apply.
|
||||
|
||||
```
|
||||
spamassassin-milter --dry-run --verbose inet:3000@localhost
|
||||
spamassassin-milter --dry-run --verbose inet:localhost:3000
|
||||
```
|
||||
|
||||
## Integration with SpamAssassin
|
||||
|
@ -272,7 +258,7 @@ messages with score 5.0 or above into ‘Junk’.
|
|||
|
||||
## Licence
|
||||
|
||||
Copyright © 2020–2021 David Bürgin
|
||||
Copyright © 2020–2022 David Bürgin
|
||||
|
||||
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
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
prefix=/usr
|
||||
libdir=${prefix}/lib/x86_64-linux-gnu
|
||||
|
||||
Name: milter
|
||||
Description: Sendmail Mail Filter API (Milter)
|
||||
Version: 8.15.2
|
||||
Libs: -L${libdir} -lmilter
|
|
@ -1,4 +1,4 @@
|
|||
.TH SPAMASSASSIN-MILTER 8 2021-12-23
|
||||
.TH SPAMASSASSIN-MILTER 8 2022-03-08
|
||||
.SH NAME
|
||||
spamassassin-milter \- milter for spam filtering with SpamAssassin
|
||||
.SH SYNOPSIS
|
||||
|
@ -33,11 +33,9 @@ The mandatory
|
|||
argument specifies the listening socket to open.
|
||||
.I SOCKET
|
||||
can be either an IPv4/IPv6 TCP socket in the form
|
||||
.BI inet: PORT @ HOST
|
||||
or
|
||||
.BI inet6: PORT @ HOST
|
||||
.BI inet: HOST : PORT
|
||||
(for example,
|
||||
.BR inet:3000@localhost ),
|
||||
.BR inet:localhost:3000 )
|
||||
or a UNIX domain socket in the form
|
||||
.BI unix: PATH
|
||||
(for example,
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
[Unit]
|
||||
Description=SpamAssassin Milter
|
||||
Documentation=man:spamassassin-milter(8)
|
||||
After=network.target
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/sbin/spamassassin-milter inet:3000@localhost
|
||||
ExecStart=/usr/sbin/spamassassin-milter inet:localhost:3000
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
|
|
355
src/callbacks.rs
355
src/callbacks.rs
|
@ -1,12 +1,35 @@
|
|||
use crate::{
|
||||
client::{Client, Spamc},
|
||||
config::{self, SynthRelayPosition},
|
||||
client::{Client, ReceivedInfo, Spamc},
|
||||
config::{Config, SynthRelayPosition},
|
||||
error::Result,
|
||||
};
|
||||
use byte_strings::c_str;
|
||||
use chrono::Local;
|
||||
use milter::{Actions, DataHandle, MacroValue, ProtocolOpts, Stage, Status};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use indymilter::{
|
||||
Actions, Callbacks, Context, EomContext, Macros, NegotiateContext, ProtoOpts, SocketInfo,
|
||||
Stage, Status,
|
||||
};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
ffi::{CStr, CString},
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
struct Connection {
|
||||
// The send operations all may fail with an I/O error. These are logged and
|
||||
// answered with `Status::Tempfail`. We don’t expect this to happen in normal
|
||||
// circumstances, only when something is wrong with `spamc` configuration or
|
||||
// operation.
|
||||
macro_rules! ok_or_tempfail {
|
||||
($expr:expr) => {
|
||||
if let ::std::result::Result::Err(e) = $expr {
|
||||
::std::eprintln!("failed to communicate with spamc: {}", e);
|
||||
return ::indymilter::Status::Tempfail;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub struct Connection {
|
||||
client_ip: IpAddr,
|
||||
helo_host: Option<String>,
|
||||
client: Option<Client>,
|
||||
|
@ -26,231 +49,280 @@ trait ConnectionMut {
|
|||
fn connection(&mut self) -> &mut Connection;
|
||||
}
|
||||
|
||||
impl ConnectionMut for DataHandle<Connection> {
|
||||
impl ConnectionMut for Option<Connection> {
|
||||
fn connection(&mut self) -> &mut Connection {
|
||||
self.borrow_mut().expect("milter context data not available")
|
||||
self.as_mut().expect("milter context data not available")
|
||||
}
|
||||
}
|
||||
|
||||
type Context = milter::Context<Connection>;
|
||||
trait MacrosExt {
|
||||
fn get_string(&self, name: &CStr) -> Option<Cow<'_, str>>;
|
||||
fn queue_id(&self) -> Cow<'_, str>;
|
||||
}
|
||||
|
||||
#[milter::on_negotiate(negotiate_callback)]
|
||||
fn handle_negotiate(
|
||||
context: Context,
|
||||
actions: Actions,
|
||||
protocol_opts: ProtocolOpts,
|
||||
) -> milter::Result<(Status, Actions, ProtocolOpts)> {
|
||||
let mut req_actions = Actions::empty();
|
||||
if !config::get().dry_run() {
|
||||
req_actions |= Actions::ADD_HEADER | Actions::REPLACE_HEADER;
|
||||
if !config::get().preserve_body() {
|
||||
req_actions |= Actions::REPLACE_BODY;
|
||||
impl MacrosExt for Macros {
|
||||
fn get_string(&self, name: &CStr) -> Option<Cow<'_, str>> {
|
||||
self.get(name).map(|v| v.to_string_lossy())
|
||||
}
|
||||
|
||||
fn queue_id(&self) -> Cow<'_, str> {
|
||||
self.get_string(c_str!("i"))
|
||||
.unwrap_or_else(|| "NONE".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_callbacks(config: Config) -> Callbacks<Connection> {
|
||||
let config = Arc::new(config);
|
||||
let config_connect = config.clone();
|
||||
let config_mail = config.clone();
|
||||
let config_data = config.clone();
|
||||
let config_header = config.clone();
|
||||
let config_eoh = config.clone();
|
||||
let config_body = config.clone();
|
||||
let config_eom = config.clone();
|
||||
|
||||
Callbacks::new()
|
||||
.on_negotiate(move |cx, _, _| Box::pin(handle_negotiate(config.clone(), cx)))
|
||||
.on_connect(move |cx, _, socket_info| {
|
||||
Box::pin(handle_connect(config_connect.clone(), cx, socket_info))
|
||||
})
|
||||
.on_helo(|cx, helo_host| Box::pin(handle_helo(cx, helo_host)))
|
||||
.on_mail(move |cx, smtp_args| Box::pin(handle_mail(config_mail.clone(), cx, smtp_args)))
|
||||
.on_rcpt(|cx, smtp_args| Box::pin(handle_rcpt(cx, smtp_args)))
|
||||
.on_data(move |cx| Box::pin(handle_data(config_data.clone(), cx)))
|
||||
.on_header(move |cx, name, value| {
|
||||
Box::pin(handle_header(config_header.clone(), cx, name, value))
|
||||
})
|
||||
.on_eoh(move |cx| Box::pin(handle_eoh(config_eoh.clone(), cx)))
|
||||
.on_body(move |cx, chunk| Box::pin(handle_body(config_body.clone(), cx, chunk)))
|
||||
.on_eom(move |cx| Box::pin(handle_eom(config_eom.clone(), cx)))
|
||||
.on_abort(|cx| Box::pin(handle_abort(cx)))
|
||||
.on_close(|cx| Box::pin(handle_close(cx)))
|
||||
}
|
||||
|
||||
async fn handle_negotiate(
|
||||
config: Arc<Config>,
|
||||
context: &mut NegotiateContext<Connection>,
|
||||
) -> Status {
|
||||
if !config.dry_run() {
|
||||
context.requested_actions |= Actions::ADD_HEADER | Actions::CHANGE_HEADER;
|
||||
if !config.preserve_body() {
|
||||
context.requested_actions |= Actions::REPLACE_BODY;
|
||||
}
|
||||
}
|
||||
|
||||
let req_protocol_opts =
|
||||
ProtocolOpts::NO_UNKNOWN | ProtocolOpts::SKIP | ProtocolOpts::HEADER_LEADING_SPACE;
|
||||
context.requested_opts |= ProtoOpts::SKIP | ProtoOpts::HEADER_LEADING_SPACE;
|
||||
|
||||
assert!(actions.contains(req_actions), "required milter actions not supported");
|
||||
assert!(protocol_opts.contains(req_protocol_opts), "required milter protocol options not supported");
|
||||
|
||||
context.api.request_macros(Stage::Connect, "")?;
|
||||
context.api.request_macros(Stage::Helo, "")?;
|
||||
context.api.request_macros(Stage::Mail, "{auth_authen}")?;
|
||||
context.api.request_macros(Stage::Rcpt, "")?;
|
||||
context.api.request_macros(Stage::Data, "i j _ {tls_version} v")?;
|
||||
context.api.request_macros(Stage::Eoh, "")?;
|
||||
context.api.request_macros(Stage::Eom, "")?;
|
||||
|
||||
Ok((Status::Continue, req_actions, req_protocol_opts))
|
||||
}
|
||||
|
||||
#[milter::on_connect(connect_callback)]
|
||||
fn handle_connect(
|
||||
mut context: Context,
|
||||
_: &str,
|
||||
socket_addr: Option<SocketAddr>,
|
||||
) -> milter::Result<Status> {
|
||||
let ip = socket_addr.map_or(IpAddr::V4(Ipv4Addr::LOCALHOST), |a| a.ip());
|
||||
|
||||
if config::get().use_trusted_networks() {
|
||||
if config::get().is_in_trusted_networks(&ip) {
|
||||
verbose!("accepted connection from trusted network address {}", ip);
|
||||
return Ok(Status::Accept);
|
||||
}
|
||||
} else if ip.is_loopback() {
|
||||
verbose!("accepted local connection");
|
||||
return Ok(Status::Accept);
|
||||
}
|
||||
|
||||
let conn = Connection::new(ip);
|
||||
context.data.replace(conn)?;
|
||||
|
||||
Ok(Status::Continue)
|
||||
}
|
||||
|
||||
#[milter::on_helo(helo_callback)]
|
||||
fn handle_helo(mut context: Context, helo_host: &str) -> Status {
|
||||
let conn = context.data.connection();
|
||||
|
||||
conn.helo_host = Some(helo_host.to_owned());
|
||||
context.requested_macros.insert(Stage::Mail, c_str!("{auth_authen}").into());
|
||||
context.requested_macros.insert(Stage::Data, c_str!("i j _ {tls_version} v").into());
|
||||
|
||||
Status::Continue
|
||||
}
|
||||
|
||||
#[milter::on_mail(mail_callback)]
|
||||
fn handle_mail(mut context: Context, smtp_args: Vec<&str>) -> milter::Result<Status> {
|
||||
async fn handle_connect(
|
||||
config: Arc<Config>,
|
||||
context: &mut Context<Connection>,
|
||||
socket_info: SocketInfo,
|
||||
) -> Status {
|
||||
let ip = match socket_info {
|
||||
SocketInfo::Inet(addr) => addr.ip(),
|
||||
_ => IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
};
|
||||
|
||||
if config.use_trusted_networks() {
|
||||
if config.is_in_trusted_networks(&ip) {
|
||||
verbose!(config, "accepted connection from trusted network address {}", ip);
|
||||
return Status::Accept;
|
||||
}
|
||||
} else if ip.is_loopback() {
|
||||
verbose!(config, "accepted local connection");
|
||||
return Status::Accept;
|
||||
}
|
||||
|
||||
context.data = Some(Connection::new(ip));
|
||||
|
||||
Status::Continue
|
||||
}
|
||||
|
||||
async fn handle_helo(context: &mut Context<Connection>, helo_host: CString) -> Status {
|
||||
let conn = context.data.connection();
|
||||
|
||||
if !config::get().auth_untrusted() {
|
||||
if let Some(login) = context.api.macro_value("{auth_authen}")? {
|
||||
verbose!("accepted message from sender authenticated as \"{}\"", login);
|
||||
return Ok(Status::Accept);
|
||||
let helo_host = helo_host.to_string_lossy();
|
||||
|
||||
conn.helo_host = Some(helo_host.into());
|
||||
|
||||
Status::Continue
|
||||
}
|
||||
|
||||
async fn handle_mail(
|
||||
config: Arc<Config>,
|
||||
context: &mut Context<Connection>,
|
||||
smtp_args: Vec<CString>,
|
||||
) -> Status {
|
||||
if !config.auth_untrusted() {
|
||||
if let Some(login) = context.macros.get_string(c_str!("{auth_authen}")) {
|
||||
verbose!(config, "accepted message from sender authenticated as \"{}\"", login);
|
||||
return Status::Accept;
|
||||
}
|
||||
}
|
||||
|
||||
let spamc = Spamc::new(config::get().spamc_args());
|
||||
let sender = smtp_args[0].to_owned();
|
||||
let conn = context.data.connection();
|
||||
|
||||
let spamc = Spamc::new(config.spamc_args());
|
||||
let sender = smtp_args[0].to_string_lossy();
|
||||
|
||||
conn.client = Some(Client::new(
|
||||
spamc,
|
||||
conn.client_ip,
|
||||
conn.helo_host.as_deref(),
|
||||
sender,
|
||||
sender.into(),
|
||||
));
|
||||
|
||||
Ok(Status::Continue)
|
||||
}
|
||||
|
||||
#[milter::on_rcpt(rcpt_callback)]
|
||||
fn handle_rcpt(mut context: Context, smtp_args: Vec<&str>) -> Status {
|
||||
let conn = context.data.connection();
|
||||
let client = conn.client.as_mut().unwrap();
|
||||
|
||||
let recipient = smtp_args[0].to_owned();
|
||||
client.add_recipient(recipient);
|
||||
|
||||
Status::Continue
|
||||
}
|
||||
|
||||
#[milter::on_data(data_callback)]
|
||||
fn handle_data(mut context: Context) -> milter::Result<Status> {
|
||||
async fn handle_rcpt(context: &mut Context<Connection>, smtp_args: Vec<CString>) -> Status {
|
||||
let conn = context.data.connection();
|
||||
let client = conn.client.as_mut().unwrap();
|
||||
|
||||
let id = queue_id(&context.api)?;
|
||||
let recipient = smtp_args[0].to_string_lossy();
|
||||
|
||||
client.add_recipient(recipient.into());
|
||||
|
||||
Status::Continue
|
||||
}
|
||||
|
||||
async fn handle_data(config: Arc<Config>, context: &mut Context<Connection>) -> Status {
|
||||
let conn = context.data.connection();
|
||||
let client = conn.client.as_mut().unwrap();
|
||||
|
||||
let id = context.macros.queue_id();
|
||||
|
||||
if let Err(e) = client.connect() {
|
||||
eprintln!("{}: failed to start spamc: {}", id, e);
|
||||
return Ok(Status::Tempfail);
|
||||
return Status::Tempfail;
|
||||
}
|
||||
|
||||
if config::get().synth_relay().is_none() {
|
||||
synthesize_relay_header(&context.api, client)?;
|
||||
if config.synth_relay().is_none() {
|
||||
ok_or_tempfail!(synthesize_relay_header(&config, &context.macros, client).await);
|
||||
}
|
||||
|
||||
Ok(Status::Continue)
|
||||
Status::Continue
|
||||
}
|
||||
|
||||
#[milter::on_header(header_callback)]
|
||||
fn handle_header(mut context: Context, name: &str, value: &str) -> milter::Result<Status> {
|
||||
async fn handle_header(
|
||||
config: Arc<Config>,
|
||||
context: &mut Context<Connection>,
|
||||
name: CString,
|
||||
value: CString,
|
||||
) -> Status {
|
||||
let conn = context.data.connection();
|
||||
let client = conn.client.as_mut().unwrap();
|
||||
|
||||
if let Some(pos) = config::get().synth_relay() {
|
||||
let name = name.to_string_lossy();
|
||||
let value = value.to_string_lossy();
|
||||
|
||||
if let Some(pos) = config.synth_relay() {
|
||||
if !client.is_synth_relay_inserted() {
|
||||
match pos {
|
||||
SynthRelayPosition::Index(i) => {
|
||||
if *i == client.header_count() {
|
||||
synthesize_relay_header(&context.api, client)?;
|
||||
ok_or_tempfail!(synthesize_relay_header(&config, &context.macros, client).await);
|
||||
}
|
||||
}
|
||||
SynthRelayPosition::Regex(regex) => {
|
||||
if !regex.is_match(&format!("{}:{}", name, value)) {
|
||||
synthesize_relay_header(&context.api, client)?;
|
||||
ok_or_tempfail!(synthesize_relay_header(&config, &context.macros, client).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.send_header(name, value)?;
|
||||
ok_or_tempfail!(client.send_header(&name, &value).await);
|
||||
|
||||
Ok(Status::Continue)
|
||||
Status::Continue
|
||||
}
|
||||
|
||||
#[milter::on_eoh(eoh_callback)]
|
||||
fn handle_eoh(mut context: Context) -> milter::Result<Status> {
|
||||
async fn handle_eoh(config: Arc<Config>, context: &mut Context<Connection>) -> Status {
|
||||
let conn = context.data.connection();
|
||||
let client = conn.client.as_mut().unwrap();
|
||||
|
||||
if config::get().synth_relay().is_some() && !client.is_synth_relay_inserted() {
|
||||
synthesize_relay_header(&context.api, client)?;
|
||||
if config.synth_relay().is_some() && !client.is_synth_relay_inserted() {
|
||||
ok_or_tempfail!(synthesize_relay_header(&config, &context.macros, client).await);
|
||||
}
|
||||
|
||||
client.send_eoh()?;
|
||||
ok_or_tempfail!(client.send_eoh().await);
|
||||
|
||||
Ok(Status::Continue)
|
||||
Status::Continue
|
||||
}
|
||||
|
||||
fn synthesize_relay_header(api: &impl MacroValue, client: &mut Client) -> milter::Result<()> {
|
||||
let id = queue_id(api)?;
|
||||
async fn synthesize_relay_header(config: &Config, macros: &Macros, client: &mut Client) -> Result<()> {
|
||||
let id = macros.queue_id();
|
||||
|
||||
if config::get().synth_relay().is_some() {
|
||||
verbose!("{}: inserting synthetic relay header at index {}", id, client.header_count());
|
||||
if config.synth_relay().is_some() {
|
||||
verbose!(config, "{}: inserting synthetic relay header at index {}", id, client.header_count());
|
||||
}
|
||||
|
||||
// Note that when SpamAssassin reports are enabled (`report_safe 1`), the
|
||||
// synthesised headers below are ‘leaked’ to users in the sense that they
|
||||
// are included inside the email MIME attachment in the new message body.
|
||||
|
||||
client.send_envelope_sender()?;
|
||||
client.send_envelope_recipients()?;
|
||||
client.send_synthesized_received_header(
|
||||
api.macro_value("_")?,
|
||||
api.macro_value("j")?.unwrap_or("localhost"),
|
||||
api.macro_value("v")?
|
||||
.and_then(|v| v.split_ascii_whitespace().next())
|
||||
.unwrap_or("Postfix"),
|
||||
api.macro_value("{tls_version}")?.is_some(),
|
||||
api.macro_value("{auth_authen}")?.is_some(),
|
||||
id,
|
||||
&Local::now().to_rfc2822(),
|
||||
)?;
|
||||
client.send_envelope_sender().await?;
|
||||
client.send_envelope_recipients().await?;
|
||||
|
||||
let info = ReceivedInfo {
|
||||
client_name_addr: macros.get_string(c_str!("_")),
|
||||
my_hostname: macros.get_string(c_str!("j")),
|
||||
mta: macros.get_string(c_str!("v")),
|
||||
tls: macros.get_string(c_str!("{tls_version}")),
|
||||
auth: macros.get_string(c_str!("{auth_authen}")),
|
||||
queue_id: &id,
|
||||
date_time: Local::now().to_rfc2822(),
|
||||
};
|
||||
|
||||
client.send_synthesized_received_header(info).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[milter::on_body(body_callback)]
|
||||
fn handle_body(mut context: Context, bytes: &[u8]) -> milter::Result<Status> {
|
||||
async fn handle_body(
|
||||
config: Arc<Config>,
|
||||
context: &mut Context<Connection>,
|
||||
chunk: Vec<u8>,
|
||||
) -> Status {
|
||||
let conn = context.data.connection();
|
||||
let client = conn.client.as_mut().unwrap();
|
||||
|
||||
client.send_body_chunk(bytes)?;
|
||||
ok_or_tempfail!(client.send_body_chunk(&chunk).await);
|
||||
|
||||
let max = config::get().max_message_size();
|
||||
Ok(if client.bytes_written() > max {
|
||||
let id = queue_id(&context.api)?;
|
||||
verbose!("{}: skipping rest of message larger than {} bytes", id, max);
|
||||
let max = config.max_message_size();
|
||||
if client.bytes_written() > max {
|
||||
let id = context.macros.queue_id();
|
||||
verbose!(config, "{}: skipping rest of message larger than {} bytes", id, max);
|
||||
client.skip_body();
|
||||
Status::Skip
|
||||
} else {
|
||||
Status::Continue
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[milter::on_eom(eom_callback)]
|
||||
fn handle_eom(mut context: Context) -> milter::Result<Status> {
|
||||
async fn handle_eom(config: Arc<Config>, context: &mut EomContext<Connection>) -> Status {
|
||||
let conn = context.data.connection();
|
||||
let client = conn.client.take().unwrap();
|
||||
|
||||
let id = queue_id(&context.api)?;
|
||||
let config = config::get();
|
||||
let id = context.macros.queue_id();
|
||||
|
||||
client.process(id, &context.api, config)
|
||||
match client.process(&id, &mut context.reply, &context.actions, &config).await {
|
||||
Ok(status) => status,
|
||||
Err(e) => {
|
||||
eprintln!("{}: failed to process message: {}", id, e);
|
||||
Status::Tempfail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[milter::on_abort(abort_callback)]
|
||||
fn handle_abort(mut context: Context) -> Status {
|
||||
async fn handle_abort(context: &mut Context<Connection>) -> Status {
|
||||
let conn = context.data.connection();
|
||||
|
||||
conn.client = None;
|
||||
|
@ -258,13 +330,8 @@ fn handle_abort(mut context: Context) -> Status {
|
|||
Status::Continue
|
||||
}
|
||||
|
||||
#[milter::on_close(close_callback)]
|
||||
fn handle_close(mut context: Context) -> milter::Result<Status> {
|
||||
context.data.take()?;
|
||||
async fn handle_close(context: &mut Context<Connection>) -> Status {
|
||||
context.data = None;
|
||||
|
||||
Ok(Status::Continue)
|
||||
}
|
||||
|
||||
fn queue_id(macros: &impl MacroValue) -> milter::Result<&str> {
|
||||
macros.macro_value("i").map(|i| i.unwrap_or("NONE"))
|
||||
Status::Continue
|
||||
}
|
||||
|
|
467
src/client.rs
467
src/client.rs
|
@ -3,25 +3,28 @@ use crate::{
|
|||
email::{self, Email, HeaderMap, HeaderRewriter},
|
||||
error::{Error, Result},
|
||||
};
|
||||
use milter::{ActionContext, SetErrorReply, Status};
|
||||
use async_trait::async_trait;
|
||||
use indymilter::{ContextActions, SetErrorReply, Status};
|
||||
use std::{
|
||||
any::Any,
|
||||
io::{self, Read, Write},
|
||||
net::IpAddr,
|
||||
os::unix::process::ExitStatusExt,
|
||||
process::{Child, Command, Stdio},
|
||||
thread::{self, JoinHandle},
|
||||
any::Any, borrow::Cow, io, net::IpAddr, os::unix::process::ExitStatusExt, pin::Pin,
|
||||
process::Stdio,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWrite, AsyncWriteExt},
|
||||
process::{Child, Command},
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait Process {
|
||||
fn connect(&mut self) -> Result<()>;
|
||||
fn writer(&mut self) -> &mut dyn Write;
|
||||
fn finish(&mut self) -> Result<Vec<u8>>;
|
||||
fn writer(&mut self) -> Pin<Box<dyn AsyncWrite + Send + '_>>;
|
||||
async fn finish(&mut self) -> Result<Vec<u8>>;
|
||||
fn as_any(&self) -> &dyn Any; // for testing only
|
||||
}
|
||||
|
||||
pub struct Spamc {
|
||||
spamc_args: &'static [String],
|
||||
spamc_args: Vec<String>,
|
||||
spamc: Option<Child>,
|
||||
stdout_reader: Option<JoinHandle<io::Result<Vec<u8>>>>,
|
||||
}
|
||||
|
@ -32,23 +35,25 @@ impl Spamc {
|
|||
None => "/usr/bin/spamc",
|
||||
};
|
||||
|
||||
pub fn new(spamc_args: &'static [String]) -> Self {
|
||||
pub fn new(spamc_args: &[String]) -> Self {
|
||||
Self {
|
||||
spamc_args,
|
||||
spamc_args: spamc_args.into(),
|
||||
spamc: None,
|
||||
stdout_reader: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Process for Spamc {
|
||||
fn connect(&mut self) -> Result<()> {
|
||||
// `Command::spawn` always succeeds when `spamc` can be invoked, even if
|
||||
// logically the command is invalid, eg if it uses non-existing options.
|
||||
let mut spamc = Command::new(Self::SPAMC_PROGRAM)
|
||||
.args(self.spamc_args)
|
||||
.args(&self.spamc_args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
let mut stdout = spamc.stdout.take().unwrap();
|
||||
|
@ -57,33 +62,33 @@ impl Process for Spamc {
|
|||
|
||||
// When processing large messages, `spamc` may begin to write its
|
||||
// response to stdout while it is still receiving parts of the message
|
||||
// body. Avoid blocking by reading stdout in a separate thread.
|
||||
self.stdout_reader = Some(thread::spawn(move || {
|
||||
// body. Avoid blocking by reading stdout in a separate task.
|
||||
self.stdout_reader = Some(tokio::spawn(async move {
|
||||
let mut output = Vec::new();
|
||||
stdout.read_to_end(&mut output)?;
|
||||
stdout.read_to_end(&mut output).await?;
|
||||
Ok(output)
|
||||
}));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn writer(&mut self) -> &mut dyn Write {
|
||||
fn writer(&mut self) -> Pin<Box<dyn AsyncWrite + Send + '_>> {
|
||||
let spamc = self.spamc.as_mut().expect("spamc process not started");
|
||||
|
||||
spamc.stdin.as_mut().unwrap()
|
||||
Box::pin(spamc.stdin.as_mut().unwrap())
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Result<Vec<u8>> {
|
||||
async fn finish(&mut self) -> Result<Vec<u8>> {
|
||||
let mut spamc = self.spamc.take().expect("spamc process not started");
|
||||
|
||||
let status = spamc.wait()?;
|
||||
let status = spamc.wait().await?;
|
||||
|
||||
let stdout = self
|
||||
.stdout_reader
|
||||
.take()
|
||||
.expect("spamc stdout reader thread not available")
|
||||
.join()
|
||||
.expect("panic in spamc stdout reader thread")?;
|
||||
.expect("spamc stdout reader task not available")
|
||||
.await
|
||||
.expect("panic in spamc stdout reader task")?;
|
||||
|
||||
if status.success() {
|
||||
Ok(stdout)
|
||||
|
@ -103,20 +108,18 @@ impl Process for Spamc {
|
|||
}
|
||||
}
|
||||
|
||||
impl Drop for Spamc {
|
||||
fn drop(&mut self) {
|
||||
// Kill (and wait on) `spamc`: a child process will continue to run even
|
||||
// after its `Child` handle has gone out of scope.
|
||||
if let Some(spamc) = self.spamc.as_mut() {
|
||||
// The results are no longer of interest at this point.
|
||||
let _ = spamc.kill();
|
||||
let _ = spamc.wait();
|
||||
}
|
||||
}
|
||||
pub struct ReceivedInfo<'macros> {
|
||||
pub client_name_addr: Option<Cow<'macros, str>>,
|
||||
pub my_hostname: Option<Cow<'macros, str>>,
|
||||
pub mta: Option<Cow<'macros, str>>,
|
||||
pub tls: Option<Cow<'macros, str>>,
|
||||
pub auth: Option<Cow<'macros, str>>,
|
||||
pub queue_id: &'macros str,
|
||||
pub date_time: String,
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
process: Box<dyn Process>,
|
||||
process: Box<dyn Process + Send>,
|
||||
client_ip: IpAddr,
|
||||
helo_host: Option<String>,
|
||||
sender: String,
|
||||
|
@ -130,7 +133,7 @@ pub struct Client {
|
|||
|
||||
impl Client {
|
||||
pub fn new(
|
||||
process: impl Process + 'static,
|
||||
process: impl Process + Send + 'static,
|
||||
client_ip: IpAddr,
|
||||
helo_host: Option<&str>,
|
||||
sender: String,
|
||||
|
@ -173,63 +176,57 @@ impl Client {
|
|||
self.process.connect()
|
||||
}
|
||||
|
||||
// Implementation note: The send operations all may fail with an I/O error,
|
||||
// and so return a `Result<()>` that is then unceremoniously unwrapped with
|
||||
// `?` in the callback functions. This is acceptable, because we don’t
|
||||
// expect this to happen in normal circumstances, only when something is
|
||||
// wrong with `spamc` configuration or operation.
|
||||
|
||||
pub fn send_envelope_sender(&mut self) -> Result<()> {
|
||||
pub async fn send_envelope_sender(&mut self) -> Result<()> {
|
||||
let buf = format!("X-Envelope-From: {}\r\n", self.sender);
|
||||
|
||||
self.process.writer().write_all(buf.as_bytes())?;
|
||||
self.process.writer().write_all(buf.as_bytes()).await?;
|
||||
self.bytes += buf.len();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_envelope_recipients(&mut self) -> Result<()> {
|
||||
pub async fn send_envelope_recipients(&mut self) -> Result<()> {
|
||||
let buf = format!("X-Envelope-To: {}\r\n", self.recipients.join(",\r\n\t"));
|
||||
|
||||
self.process.writer().write_all(buf.as_bytes())?;
|
||||
self.process.writer().write_all(buf.as_bytes()).await?;
|
||||
self.bytes += buf.len();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_synthesized_received_header(
|
||||
&mut self,
|
||||
client_name_addr: Option<&str>,
|
||||
my_hostname: &str,
|
||||
mta: &str,
|
||||
tls: bool,
|
||||
auth: bool,
|
||||
queue_id: &str,
|
||||
date_time: &str,
|
||||
) -> Result<()> {
|
||||
pub async fn send_synthesized_received_header(&mut self, info: ReceivedInfo<'_>) -> Result<()> {
|
||||
// Sending this ‘Received’ header is crucial: Milters don’t see the
|
||||
// MTA’s own ‘Received’ header. However, SpamAssassin draws a lot of
|
||||
// information from that header. So we make one up and send it along.
|
||||
|
||||
let client_ip = self.client_ip.to_string();
|
||||
let helo_host = self.helo_host.as_deref().unwrap_or(&client_ip);
|
||||
|
||||
let client_name_addr = info.client_name_addr.as_deref().unwrap_or(&client_ip);
|
||||
let my_hostname = info.my_hostname.as_deref().unwrap_or("localhost");
|
||||
let mta = info
|
||||
.mta
|
||||
.as_ref()
|
||||
.and_then(|v| v.split_ascii_whitespace().next())
|
||||
.unwrap_or("Postfix");
|
||||
|
||||
let buf = format!(
|
||||
"Received: from {helo} ({client})\r\n\
|
||||
\tby {hostname} ({mta}) with ESMTP{tls}{auth} id {id};\r\n\
|
||||
\t{date_time}\r\n\
|
||||
\t(envelope-from {sender})\r\n",
|
||||
helo = self.helo_host.as_ref().unwrap_or(&client_ip),
|
||||
client = client_name_addr.unwrap_or(&client_ip),
|
||||
helo = helo_host,
|
||||
client = client_name_addr,
|
||||
hostname = my_hostname,
|
||||
mta = mta,
|
||||
tls = if tls { "S" } else { "" },
|
||||
auth = if auth { "A" } else { "" },
|
||||
id = queue_id,
|
||||
date_time = date_time,
|
||||
tls = if info.tls.is_some() { "S" } else { "" },
|
||||
auth = if info.auth.is_some() { "A" } else { "" },
|
||||
id = info.queue_id,
|
||||
date_time = info.date_time,
|
||||
sender = self.sender
|
||||
);
|
||||
|
||||
self.process.writer().write_all(buf.as_bytes())?;
|
||||
self.process.writer().write_all(buf.as_bytes()).await?;
|
||||
self.bytes += buf.len();
|
||||
|
||||
self.synth_relay_inserted = true;
|
||||
|
@ -237,7 +234,7 @@ impl Client {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_header(&mut self, name: &str, value: &str) -> Result<()> {
|
||||
pub async fn send_header(&mut self, name: &str, value: &str) -> Result<()> {
|
||||
// As requested during milter protocol negotiation, the value includes
|
||||
// leading whitespace. This lets us pass on whitespace exactly as is.
|
||||
let value = email::ensure_crlf(value);
|
||||
|
@ -250,7 +247,7 @@ impl Client {
|
|||
self.headers.insert_if_absent(name, value);
|
||||
}
|
||||
|
||||
self.process.writer().write_all(buf.as_bytes())?;
|
||||
self.process.writer().write_all(buf.as_bytes()).await?;
|
||||
self.bytes += buf.len();
|
||||
|
||||
self.header_count += 1;
|
||||
|
@ -258,29 +255,30 @@ impl Client {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_eoh(&mut self) -> Result<()> {
|
||||
pub async fn send_eoh(&mut self) -> Result<()> {
|
||||
let eoh = b"\r\n";
|
||||
|
||||
self.process.writer().write_all(eoh)?;
|
||||
self.process.writer().write_all(eoh).await?;
|
||||
self.bytes += eoh.len();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_body_chunk(&mut self, bytes: &[u8]) -> Result<()> {
|
||||
self.process.writer().write_all(bytes)?;
|
||||
pub async fn send_body_chunk(&mut self, bytes: &[u8]) -> Result<()> {
|
||||
self.process.writer().write_all(bytes).await?;
|
||||
self.bytes += bytes.len();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn process(
|
||||
pub async fn process(
|
||||
mut self,
|
||||
id: &str,
|
||||
actions: &(impl ActionContext + SetErrorReply),
|
||||
reply: &mut impl SetErrorReply,
|
||||
actions: &impl ContextActions,
|
||||
config: &Config,
|
||||
) -> milter::Result<Status> {
|
||||
let output = match self.process.finish() {
|
||||
) -> Result<Status> {
|
||||
let output = match self.process.finish().await {
|
||||
Ok(output) => output,
|
||||
Err(e) => {
|
||||
eprintln!("{}: failed to complete spamc communication: {}", id, e);
|
||||
|
@ -304,18 +302,18 @@ impl Client {
|
|||
let spam = rewriter.is_flagged_spam();
|
||||
|
||||
if spam && config.reject_spam() {
|
||||
return reject_spam(id, actions, config);
|
||||
return reject_spam(id, reply, config);
|
||||
}
|
||||
|
||||
rewriter.rewrite_spam_assassin_headers(id, actions)?;
|
||||
rewriter.rewrite_spam_assassin_headers(id, actions).await?;
|
||||
|
||||
if spam {
|
||||
if !config.preserve_headers() {
|
||||
rewriter.rewrite_rewrite_headers(id, actions)?;
|
||||
rewriter.rewrite_rewrite_headers(id, actions).await?;
|
||||
}
|
||||
if !config.preserve_body() {
|
||||
rewriter.rewrite_report_headers(id, actions)?;
|
||||
replace_body(id, email.body, self.skipped, actions, config)?;
|
||||
rewriter.rewrite_report_headers(id, actions).await?;
|
||||
replace_body(id, email.body, self.skipped, actions, config).await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -323,15 +321,15 @@ impl Client {
|
|||
}
|
||||
}
|
||||
|
||||
fn reject_spam(id: &str, actions: &impl SetErrorReply, config: &Config) -> milter::Result<Status> {
|
||||
fn reject_spam(id: &str, reply: &mut impl SetErrorReply, config: &Config) -> Result<Status> {
|
||||
if config.dry_run() {
|
||||
verbose!(config, "{}: rejected message flagged as spam [dry run, not done]", id);
|
||||
Ok(Status::Accept)
|
||||
} else {
|
||||
actions.set_error_reply(
|
||||
reply.set_error_reply(
|
||||
config.reply_code(),
|
||||
Some(config.reply_status_code()),
|
||||
config.reply_text().lines().collect(),
|
||||
config.reply_text().lines(),
|
||||
)?;
|
||||
|
||||
verbose!(config, "{}: rejected message flagged as spam", id);
|
||||
|
@ -343,13 +341,13 @@ fn reject_spam(id: &str, actions: &impl SetErrorReply, config: &Config) -> milte
|
|||
}
|
||||
}
|
||||
|
||||
fn replace_body(
|
||||
async fn replace_body(
|
||||
id: &str,
|
||||
body: &[u8],
|
||||
skipped: bool,
|
||||
actions: &impl ActionContext,
|
||||
actions: &impl ContextActions,
|
||||
config: &Config,
|
||||
) -> milter::Result<()> {
|
||||
) -> Result<()> {
|
||||
// Do not replace the message body when part of it was skipped. This only
|
||||
// occurs when the milter’s max message size is less than that of `spamc`.
|
||||
// This condition ensures message integrity in such a misconfigured setup.
|
||||
|
@ -360,18 +358,20 @@ fn replace_body(
|
|||
id,
|
||||
config.max_message_size()
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
email::replace_body(id, body, actions, config)
|
||||
email::replace_body(id, body, actions, config).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{cell::RefCell, net::Ipv4Addr};
|
||||
use byte_strings::c_str;
|
||||
use indymilter::{ActionError, IntoCString, SmtpReplyError};
|
||||
use std::{ffi::CString, net::Ipv4Addr, result, sync::Mutex};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
#[derive(Debug, Default)]
|
||||
struct MockSpamc {
|
||||
buf: Vec<u8>,
|
||||
output: Option<Vec<u8>>,
|
||||
|
@ -383,20 +383,24 @@ mod tests {
|
|||
}
|
||||
|
||||
fn with_output(output: Vec<u8>) -> Self {
|
||||
Self { output: Some(output), ..Default::default() }
|
||||
Self {
|
||||
output: Some(output),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Process for MockSpamc {
|
||||
fn connect(&mut self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn writer(&mut self) -> &mut dyn Write {
|
||||
&mut self.buf
|
||||
fn writer(&mut self) -> Pin<Box<dyn AsyncWrite + Send + '_>> {
|
||||
Box::pin(&mut self.buf)
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Result<Vec<u8>> {
|
||||
async fn finish(&mut self) -> Result<Vec<u8>> {
|
||||
Ok(if let Some(output) = &self.output {
|
||||
output.clone()
|
||||
} else {
|
||||
|
@ -413,101 +417,158 @@ mod tests {
|
|||
process.as_any().downcast_ref().unwrap()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
enum Action {
|
||||
AddHeader(String, String),
|
||||
InsertHeader(usize, String, String),
|
||||
ReplaceHeader(String, usize, Option<String>),
|
||||
AppendBodyChunk(Vec<u8>),
|
||||
SetErrorReply(String, Option<String>, Vec<String>),
|
||||
AddHeader(CString, CString),
|
||||
InsertHeader(i32, CString, CString),
|
||||
ChangeHeader(CString, i32, Option<CString>),
|
||||
ReplaceBody(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct MockActionContext {
|
||||
called: RefCell<Vec<Action>>,
|
||||
#[derive(Debug, Default)]
|
||||
struct MockEomActions {
|
||||
called: Mutex<Vec<Action>>,
|
||||
}
|
||||
|
||||
impl MockActionContext {
|
||||
impl MockEomActions {
|
||||
fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionContext for MockActionContext {
|
||||
fn add_header(&self, name: &str, value: &str) -> milter::Result<()> {
|
||||
let action = Action::AddHeader(name.to_owned(), value.to_owned());
|
||||
self.called.borrow_mut().push(action);
|
||||
#[async_trait]
|
||||
impl ContextActions for MockEomActions {
|
||||
async fn add_header<'cx, 'k, 'v>(
|
||||
&'cx self,
|
||||
name: impl IntoCString + Send + 'k,
|
||||
value: impl IntoCString + Send + 'v,
|
||||
) -> result::Result<(), ActionError> {
|
||||
let action = Action::AddHeader(name.into_c_string(), value.into_c_string());
|
||||
self.called.lock().unwrap().push(action);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_header(&self, index: usize, name: &str, value: &str) -> milter::Result<()> {
|
||||
let action = Action::InsertHeader(index, name.to_owned(), value.to_owned());
|
||||
self.called.borrow_mut().push(action);
|
||||
async fn insert_header<'cx, 'k, 'v>(
|
||||
&'cx self,
|
||||
index: i32,
|
||||
name: impl IntoCString + Send + 'k,
|
||||
value: impl IntoCString + Send + 'v,
|
||||
) -> result::Result<(), ActionError> {
|
||||
let action = Action::InsertHeader(index, name.into_c_string(), value.into_c_string());
|
||||
self.called.lock().unwrap().push(action);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_header(&self, name: &str, index: usize, value: Option<&str>) -> milter::Result<()> {
|
||||
let action = Action::ReplaceHeader(name.to_owned(), index, value.map(|v| v.to_owned()));
|
||||
self.called.borrow_mut().push(action);
|
||||
async fn change_header<'cx, 'k, 'v>(
|
||||
&'cx self,
|
||||
name: impl IntoCString + Send + 'k,
|
||||
index: i32,
|
||||
value: Option<impl IntoCString + Send + 'v>,
|
||||
) -> result::Result<(), ActionError> {
|
||||
let action = Action::ChangeHeader(
|
||||
name.into_c_string(),
|
||||
index,
|
||||
value.map(|v| v.into_c_string()),
|
||||
);
|
||||
self.called.lock().unwrap().push(action);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_body_chunk(&self, bytes: &[u8]) -> milter::Result<()> {
|
||||
let action = Action::AppendBodyChunk(bytes.to_owned());
|
||||
self.called.borrow_mut().push(action);
|
||||
async fn replace_body<'cx, 'a>(
|
||||
&'cx self,
|
||||
chunk: &'a [u8],
|
||||
) -> result::Result<(), ActionError> {
|
||||
let action = Action::ReplaceBody(chunk.to_vec());
|
||||
self.called.lock().unwrap().push(action);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_sender(&self, _: &str, _: Option<&str>) -> milter::Result<()> {
|
||||
unimplemented!();
|
||||
async fn change_sender<'cx, 'a, 'b>(
|
||||
&'cx self,
|
||||
_: impl IntoCString + Send + 'a,
|
||||
_: Option<impl IntoCString + Send + 'b>,
|
||||
) -> result::Result<(), ActionError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn add_recipient(&self, _: &str, _: Option<&str>) -> milter::Result<()> {
|
||||
unimplemented!();
|
||||
async fn add_recipient<'cx, 'a>(
|
||||
&'cx self,
|
||||
_: impl IntoCString + Send + 'a,
|
||||
) -> result::Result<(), ActionError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn remove_recipient(&self, _: &str) -> milter::Result<()> {
|
||||
unimplemented!();
|
||||
async fn add_recipient_ext<'cx, 'a, 'b>(
|
||||
&'cx self,
|
||||
_: impl IntoCString + Send + 'a,
|
||||
_: Option<impl IntoCString + Send + 'b>,
|
||||
) -> result::Result<(), ActionError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn quarantine(&self, _: &str) -> milter::Result<()> {
|
||||
unimplemented!();
|
||||
async fn delete_recipient<'cx, 'a>(
|
||||
&'cx self,
|
||||
_: impl IntoCString + Send + 'a,
|
||||
) -> result::Result<(), ActionError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn signal_progress(&self) -> milter::Result<()> {
|
||||
unimplemented!();
|
||||
async fn progress<'cx>(&'cx self) -> result::Result<(), ActionError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn quarantine<'cx, 'a>(
|
||||
&'cx self,
|
||||
_: impl IntoCString + Send + 'a,
|
||||
) -> result::Result<(), ActionError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
impl SetErrorReply for MockActionContext {
|
||||
fn set_error_reply(
|
||||
&self,
|
||||
code: &str,
|
||||
ext_code: Option<&str>,
|
||||
msg_lines: Vec<&str>,
|
||||
) -> milter::Result<()> {
|
||||
let action = Action::SetErrorReply(
|
||||
code.to_owned(),
|
||||
ext_code.map(|c| c.to_owned()),
|
||||
msg_lines.into_iter().map(|l| l.to_owned()).collect(),
|
||||
);
|
||||
self.called.borrow_mut().push(action);
|
||||
#[derive(Debug, Default)]
|
||||
struct MockSmtpReply {
|
||||
error_reply: Option<(String, Option<String>, Vec<CString>)>,
|
||||
}
|
||||
|
||||
impl MockSmtpReply {
|
||||
fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl SetErrorReply for MockSmtpReply {
|
||||
fn set_error_reply<I, T>(
|
||||
&mut self,
|
||||
rcode: &str,
|
||||
xcode: Option<&str>,
|
||||
message: I,
|
||||
) -> result::Result<(), SmtpReplyError>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: IntoCString,
|
||||
{
|
||||
self.error_reply = Some((
|
||||
rcode.into(),
|
||||
xcode.map(|c| c.into()),
|
||||
message.into_iter().map(|l| l.into_c_string()).collect(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const IP: IpAddr = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
|
||||
const HELO: &str = "helo";
|
||||
const ID: &str = "NONE";
|
||||
|
||||
#[test]
|
||||
fn client_send_writes_bytes() {
|
||||
#[tokio::test]
|
||||
async fn client_send_writes_bytes() {
|
||||
let spamc = MockSpamc::new();
|
||||
|
||||
let mut client = Client::new(spamc, IP, Some(HELO), String::from("sender"));
|
||||
client.send_header("name1", " value1").unwrap();
|
||||
client.send_header("name2", " value2\n\tcontinued").unwrap();
|
||||
client.send_eoh().unwrap();
|
||||
client.send_body_chunk(b"body").unwrap();
|
||||
let mut client = Client::new(spamc, IP, Some(HELO), "sender".into());
|
||||
client.send_header("name1", " value1").await.unwrap();
|
||||
client.send_header("name2", " value2\n\tcontinued").await.unwrap();
|
||||
client.send_eoh().await.unwrap();
|
||||
client.send_body_chunk(b"body").await.unwrap();
|
||||
|
||||
assert_eq!(client.bytes_written(), 48);
|
||||
assert_eq!(
|
||||
|
@ -516,20 +577,20 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_send_envelope_addresses() {
|
||||
#[tokio::test]
|
||||
async fn client_send_envelope_addresses() {
|
||||
let spamc = MockSpamc::new();
|
||||
|
||||
let sender = "<sender@gluet.ch>";
|
||||
let recipient1 = "<recipient1@gluet.ch>";
|
||||
let recipient2 = "<recipient2@gluet.ch>";
|
||||
|
||||
let mut client = Client::new(spamc, IP, Some(HELO), String::from(sender));
|
||||
client.add_recipient(String::from(recipient1));
|
||||
client.add_recipient(String::from(recipient2));
|
||||
let mut client = Client::new(spamc, IP, Some(HELO), sender.into());
|
||||
client.add_recipient(recipient1.into());
|
||||
client.add_recipient(recipient2.into());
|
||||
|
||||
client.send_envelope_sender().unwrap();
|
||||
client.send_envelope_recipients().unwrap();
|
||||
client.send_envelope_sender().await.unwrap();
|
||||
client.send_envelope_recipients().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
as_mock_spamc(client.process.as_ref()).buf,
|
||||
|
@ -538,90 +599,104 @@ mod tests {
|
|||
X-Envelope-To: {},\r\n\
|
||||
\t{}\r\n",
|
||||
sender, recipient1, recipient2
|
||||
).as_bytes()
|
||||
)
|
||||
.as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_process_invalid_response() {
|
||||
#[tokio::test]
|
||||
async fn client_process_invalid_response() {
|
||||
let spamc = MockSpamc::with_output(b"invalid message response".to_vec());
|
||||
let actions = MockActionContext::new();
|
||||
|
||||
let client = Client::new(spamc, IP, Some(HELO), "sender".into());
|
||||
|
||||
let mut reply = MockSmtpReply::new();
|
||||
let actions = MockEomActions::new();
|
||||
let config = Default::default();
|
||||
|
||||
let client = Client::new(spamc, IP, Some(HELO), String::from("sender"));
|
||||
let status = client.process("id", &actions, &config).unwrap();
|
||||
let status = client.process(ID, &mut reply, &actions, &config).await.unwrap();
|
||||
|
||||
assert_eq!(status, Status::Tempfail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_process_reject_spam() {
|
||||
#[tokio::test]
|
||||
async fn client_process_reject_spam() {
|
||||
let spamc = MockSpamc::with_output(b"X-Spam-Flag: YES\r\n\r\n".to_vec());
|
||||
let actions = MockActionContext::new();
|
||||
let mut builder = Config::builder();
|
||||
builder.reject_spam(true);
|
||||
let config = builder.build();
|
||||
|
||||
let client = Client::new(spamc, IP, Some(HELO), String::from("sender"));
|
||||
let status = client.process("id", &actions, &config).unwrap();
|
||||
let client = Client::new(spamc, IP, Some(HELO), "sender".into());
|
||||
|
||||
let mut reply = MockSmtpReply::new();
|
||||
let actions = MockEomActions::new();
|
||||
let config = Config::builder().reject_spam(true).build();
|
||||
|
||||
let status = client.process(ID, &mut reply, &actions, &config).await.unwrap();
|
||||
|
||||
assert_eq!(status, Status::Reject);
|
||||
assert_eq!(
|
||||
actions.called.borrow().as_slice(),
|
||||
[
|
||||
Action::SetErrorReply(
|
||||
"550".into(),
|
||||
Some("5.7.1".into()),
|
||||
vec!["Spam message refused".into()],
|
||||
),
|
||||
]
|
||||
reply.error_reply,
|
||||
Some((
|
||||
"550".into(),
|
||||
Some("5.7.1".into()),
|
||||
vec![c_str!("Spam message refused").into()],
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_process_rewrite_spam() {
|
||||
#[tokio::test]
|
||||
async fn client_process_rewrite_spam() {
|
||||
let spamc = MockSpamc::with_output(
|
||||
b"X-Spam-Flag: YES\r\nX-Spam-Level: *****\r\n\r\nReport".to_vec(),
|
||||
);
|
||||
let actions = MockActionContext::new();
|
||||
|
||||
let mut client = Client::new(spamc, IP, Some(HELO), "sender".into());
|
||||
|
||||
client.send_header("x-spam-level", " *").await.unwrap();
|
||||
client.send_header("x-spam-report", " ...").await.unwrap();
|
||||
|
||||
let mut reply = MockSmtpReply::new();
|
||||
let actions = MockEomActions::new();
|
||||
let config = Default::default();
|
||||
|
||||
let mut client = Client::new(spamc, IP, Some(HELO), String::from("sender"));
|
||||
client.send_header("x-spam-level", " *").unwrap();
|
||||
client.send_header("x-spam-report", " ...").unwrap();
|
||||
|
||||
let status = client.process("id", &actions, &config).unwrap();
|
||||
let status = client.process(ID, &mut reply, &actions, &config).await.unwrap();
|
||||
|
||||
assert_eq!(status, Status::Continue);
|
||||
|
||||
let called = actions.called.lock().unwrap();
|
||||
assert_eq!(
|
||||
actions.called.borrow().as_slice(),
|
||||
called.as_slice(),
|
||||
[
|
||||
Action::ReplaceHeader("X-Spam-Level".into(), 1, None),
|
||||
Action::InsertHeader(0, "X-Spam-Level".into(), " *****".into()),
|
||||
Action::InsertHeader(0, "X-Spam-Flag".into(), " YES".into()),
|
||||
Action::ReplaceHeader("x-spam-report".into(), 1, None),
|
||||
Action::AppendBodyChunk(b"Report".to_vec()),
|
||||
Action::ChangeHeader(c_str!("X-Spam-Level").into(), 1, None),
|
||||
Action::InsertHeader(0, c_str!("X-Spam-Level").into(), c_str!(" *****").into()),
|
||||
Action::InsertHeader(0, c_str!("X-Spam-Flag").into(), c_str!(" YES").into()),
|
||||
Action::ChangeHeader(c_str!("x-spam-report").into(), 1, None),
|
||||
Action::ReplaceBody(b"Report".to_vec()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_process_skipped_body_not_replaced() {
|
||||
#[tokio::test]
|
||||
async fn client_process_skipped_body_not_replaced() {
|
||||
let spamc = MockSpamc::with_output(
|
||||
b"X-Spam-Flag: YES\r\nX-Spam-Level: *****\r\n\r\nReport".to_vec(),
|
||||
);
|
||||
let actions = MockActionContext::new();
|
||||
let config = Default::default();
|
||||
|
||||
let mut client = Client::new(spamc, IP, Some(HELO), String::from("sender"));
|
||||
let mut client = Client::new(spamc, IP, Some(HELO), "sender".into());
|
||||
client.skip_body();
|
||||
|
||||
let status = client.process("id", &actions, &config).unwrap();
|
||||
let mut reply = MockSmtpReply::new();
|
||||
let actions = MockEomActions::new();
|
||||
let config = Default::default();
|
||||
|
||||
let status = client.process(ID, &mut reply, &actions, &config).await.unwrap();
|
||||
|
||||
assert_eq!(status, Status::Continue);
|
||||
|
||||
let called = actions.called.borrow();
|
||||
assert!(called.contains(&Action::InsertHeader(0, "X-Spam-Level".into(), " *****".into())));
|
||||
assert!(!called.contains(&Action::AppendBodyChunk(b"Report".to_vec())));
|
||||
let called = actions.called.lock().unwrap();
|
||||
assert!(called.contains(&Action::InsertHeader(
|
||||
0,
|
||||
c_str!("X-Spam-Level").into(),
|
||||
c_str!(" *****").into()
|
||||
)));
|
||||
assert!(!called.contains(&Action::ReplaceBody(b"Report".to_vec())));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,9 @@ where
|
|||
K: AsRef<str>,
|
||||
{
|
||||
pub fn new() -> Self {
|
||||
Self { entries: Default::default() }
|
||||
Self {
|
||||
entries: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&K, &V)> {
|
||||
|
@ -29,15 +31,22 @@ where
|
|||
}
|
||||
|
||||
pub fn contains_key<Q: AsRef<str>>(&self, key: Q) -> bool {
|
||||
self.iter().any(|e| e.0.as_ref().eq_ignore_ascii_case(key.as_ref()))
|
||||
self.iter()
|
||||
.any(|e| e.0.as_ref().eq_ignore_ascii_case(key.as_ref()))
|
||||
}
|
||||
|
||||
pub fn get<Q: AsRef<str>>(&self, key: Q) -> Option<&V> {
|
||||
self.iter().find(|e| e.0.as_ref().eq_ignore_ascii_case(key.as_ref())).map(|e| e.1)
|
||||
self.iter()
|
||||
.find(|e| e.0.as_ref().eq_ignore_ascii_case(key.as_ref()))
|
||||
.map(|e| e.1)
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, key: K, value: V) -> Option<V> {
|
||||
match self.entries.iter_mut().find(|e| e.0.as_ref().eq_ignore_ascii_case(key.as_ref())) {
|
||||
match self
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|e| e.0.as_ref().eq_ignore_ascii_case(key.as_ref()))
|
||||
{
|
||||
None => {
|
||||
self.entries.push((key, value));
|
||||
None
|
||||
|
@ -65,23 +74,25 @@ impl<V> StrVecMap<String, V> {
|
|||
|
||||
/// A vector set containing ASCII-case-insensitive `AsRef<str>` elements.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct StrVecSet<E> {
|
||||
map: StrVecMap<E, ()>,
|
||||
pub struct StrVecSet<T> {
|
||||
map: StrVecMap<T, ()>,
|
||||
}
|
||||
|
||||
impl<E> StrVecSet<E>
|
||||
impl<T> StrVecSet<T>
|
||||
where
|
||||
E: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
pub fn new() -> Self {
|
||||
Self { map: StrVecMap::new() }
|
||||
Self {
|
||||
map: StrVecMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains<Q: AsRef<str>>(&self, key: Q) -> bool {
|
||||
self.map.contains_key(key)
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, key: E) -> bool {
|
||||
pub fn insert(&mut self, key: T) -> bool {
|
||||
self.map.insert(key, ()).is_none()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
use ipnet::IpNet;
|
||||
use once_cell::sync::OnceCell;
|
||||
use regex::{Error, Regex};
|
||||
use std::{collections::HashSet, net::IpAddr, str::FromStr};
|
||||
|
||||
/// A builder for SpamAssassin Milter configuration objects.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConfigBuilder {
|
||||
milter_debug_level: i32,
|
||||
use_trusted_networks: bool,
|
||||
trusted_networks: HashSet<IpNet>,
|
||||
auth_untrusted: bool,
|
||||
|
@ -24,28 +22,23 @@ pub struct ConfigBuilder {
|
|||
}
|
||||
|
||||
impl ConfigBuilder {
|
||||
pub fn milter_debug_level(&mut self, value: i32) -> &mut Self {
|
||||
self.milter_debug_level = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn use_trusted_networks(&mut self, value: bool) -> &mut Self {
|
||||
pub fn use_trusted_networks(mut self, value: bool) -> Self {
|
||||
self.use_trusted_networks = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn trusted_network(&mut self, net: IpNet) -> &mut Self {
|
||||
pub fn trusted_network(mut self, net: IpNet) -> Self {
|
||||
self.use_trusted_networks = true;
|
||||
self.trusted_networks.insert(net);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn auth_untrusted(&mut self, value: bool) -> &mut Self {
|
||||
pub fn auth_untrusted(mut self, value: bool) -> Self {
|
||||
self.auth_untrusted = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn spamc_args<I, S>(&mut self, args: I) -> &mut Self
|
||||
pub fn spamc_args<I, S>(mut self, args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
|
@ -54,58 +47,58 @@ impl ConfigBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn synth_relay(&mut self, value: SynthRelayPosition) -> &mut Self {
|
||||
pub fn synth_relay(mut self, value: SynthRelayPosition) -> Self {
|
||||
self.synth_relay = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn max_message_size(&mut self, value: usize) -> &mut Self {
|
||||
pub fn max_message_size(mut self, value: usize) -> Self {
|
||||
self.max_message_size = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn dry_run(&mut self, value: bool) -> &mut Self {
|
||||
pub fn dry_run(mut self, value: bool) -> Self {
|
||||
self.dry_run = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn reject_spam(&mut self, value: bool) -> &mut Self {
|
||||
pub fn reject_spam(mut self, value: bool) -> Self {
|
||||
self.reject_spam = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn reply_code(&mut self, value: String) -> &mut Self {
|
||||
pub fn reply_code(mut self, value: String) -> Self {
|
||||
self.reply_code = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn reply_status_code(&mut self, value: String) -> &mut Self {
|
||||
pub fn reply_status_code(mut self, value: String) -> Self {
|
||||
self.reply_status_code = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn reply_text(&mut self, value: String) -> &mut Self {
|
||||
pub fn reply_text(mut self, value: String) -> Self {
|
||||
self.reply_text = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn preserve_headers(&mut self, value: bool) -> &mut Self {
|
||||
pub fn preserve_headers(mut self, value: bool) -> Self {
|
||||
self.preserve_headers = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn preserve_body(&mut self, value: bool) -> &mut Self {
|
||||
pub fn preserve_body(mut self, value: bool) -> Self {
|
||||
self.preserve_body = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn verbose(&mut self, value: bool) -> &mut Self {
|
||||
pub fn verbose(mut self, value: bool) -> Self {
|
||||
self.verbose = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Config {
|
||||
// TODO These invariants are enforced in `main`. In a future revision,
|
||||
// These invariants are enforced in `main`. In a future revision,
|
||||
// consider replacing the assertions with a `Result` return type.
|
||||
assert!(
|
||||
self.use_trusted_networks || self.trusted_networks.is_empty(),
|
||||
|
@ -119,7 +112,6 @@ impl ConfigBuilder {
|
|||
);
|
||||
|
||||
Config {
|
||||
milter_debug_level: self.milter_debug_level,
|
||||
use_trusted_networks: self.use_trusted_networks,
|
||||
trusted_networks: self.trusted_networks,
|
||||
auth_untrusted: self.auth_untrusted,
|
||||
|
@ -145,7 +137,6 @@ fn is_valid_reply_code(s: &str) -> bool {
|
|||
impl Default for ConfigBuilder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
milter_debug_level: Default::default(),
|
||||
use_trusted_networks: Default::default(),
|
||||
trusted_networks: Default::default(),
|
||||
auth_untrusted: Default::default(),
|
||||
|
@ -156,10 +147,10 @@ impl Default for ConfigBuilder {
|
|||
reject_spam: Default::default(),
|
||||
// This reply code and enhanced status code are the most appropriate
|
||||
// choices according to RFCs 5321 and 3463.
|
||||
reply_code: String::from("550"),
|
||||
reply_status_code: String::from("5.7.1"),
|
||||
reply_code: "550".into(),
|
||||
reply_status_code: "5.7.1".into(),
|
||||
// Generic reply text that makes no mention of SpamAssassin.
|
||||
reply_text: String::from("Spam message refused"),
|
||||
reply_text: "Spam message refused".into(),
|
||||
preserve_headers: Default::default(),
|
||||
preserve_body: Default::default(),
|
||||
verbose: Default::default(),
|
||||
|
@ -170,7 +161,6 @@ impl Default for ConfigBuilder {
|
|||
/// A configuration object for SpamAssassin Milter.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
milter_debug_level: i32,
|
||||
use_trusted_networks: bool,
|
||||
trusted_networks: HashSet<IpNet>,
|
||||
auth_untrusted: bool,
|
||||
|
@ -192,10 +182,6 @@ impl Config {
|
|||
Default::default()
|
||||
}
|
||||
|
||||
pub fn milter_debug_level(&self) -> i32 {
|
||||
self.milter_debug_level
|
||||
}
|
||||
|
||||
pub fn use_trusted_networks(&self) -> bool {
|
||||
self.use_trusted_networks
|
||||
}
|
||||
|
@ -278,25 +264,15 @@ impl FromStr for SynthRelayPosition {
|
|||
}
|
||||
}
|
||||
|
||||
static CONFIG: OnceCell<Config> = OnceCell::new();
|
||||
|
||||
pub fn init(config: Config) {
|
||||
CONFIG.set(config).expect("configuration already initialized");
|
||||
}
|
||||
|
||||
pub fn get() -> &'static Config {
|
||||
CONFIG.get().expect("configuration not initialized")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn trusted_networks_config() {
|
||||
let mut builder = Config::builder();
|
||||
builder.trusted_network("127.0.0.1/8".parse().unwrap());
|
||||
let config = builder.build();
|
||||
let config = Config::builder()
|
||||
.trusted_network("127.0.0.1/8".parse().unwrap())
|
||||
.build();
|
||||
|
||||
assert!(config.use_trusted_networks());
|
||||
assert!(config.is_in_trusted_networks(&"127.0.0.1".parse().unwrap()));
|
||||
|
@ -305,10 +281,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn spamc_args_extends_args() {
|
||||
let mut builder = Config::builder();
|
||||
builder.spamc_args(&["-p", "3030"]);
|
||||
builder.spamc_args(&["-x"]);
|
||||
let config = builder.build();
|
||||
let config = Config::builder()
|
||||
.spamc_args(&["-p", "3030"])
|
||||
.spamc_args(&["-x"])
|
||||
.build();
|
||||
|
||||
assert_eq!(
|
||||
config.spamc_args(),
|
||||
|
|
105
src/email.rs
105
src/email.rs
|
@ -3,20 +3,21 @@ use crate::{
|
|||
config::Config,
|
||||
error::{Error, Result},
|
||||
};
|
||||
use milter::ActionContext;
|
||||
use indymilter::ContextActions;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::{
|
||||
ffi::CString,
|
||||
fmt::{self, Display, Formatter},
|
||||
str,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct Header<'a> {
|
||||
pub name: &'a str,
|
||||
pub value: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct Email<'a> {
|
||||
pub header: Vec<Header<'a>>,
|
||||
pub body: &'a [u8],
|
||||
|
@ -107,7 +108,7 @@ pub fn is_spam_assassin_header(name: &str) -> bool {
|
|||
|
||||
// Values use CRLF line breaks and include leading whitespace.
|
||||
pub type HeaderMap = StrVecMap<String, String>;
|
||||
pub type HeaderSet<'e> = StrVecSet<&'e str>;
|
||||
pub type HeaderSet<'a> = StrVecSet<&'a str>;
|
||||
|
||||
pub static REWRITE_HEADERS: Lazy<HeaderSet<'static>> = Lazy::new(|| {
|
||||
let mut h = HeaderSet::new();
|
||||
|
@ -230,52 +231,54 @@ impl<'a, 'c> HeaderRewriter<'a, 'c> {
|
|||
self.spam_flag.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn rewrite_spam_assassin_headers(
|
||||
pub async fn rewrite_spam_assassin_headers(
|
||||
&self,
|
||||
id: &str,
|
||||
actions: &impl ActionContext,
|
||||
) -> milter::Result<()> {
|
||||
actions: &impl ContextActions,
|
||||
) -> Result<()> {
|
||||
let mods = self.spam_assassin_mods.iter();
|
||||
if self.prepend.unwrap_or(false) {
|
||||
// Prepend ‘X-Spam-’ headers in reverse order, so that they appear
|
||||
// in the order received from SpamAssassin.
|
||||
execute_mods(id, mods.rev(), actions, self.config)?;
|
||||
execute_mods(id, mods.rev(), actions, self.config).await?;
|
||||
} else {
|
||||
execute_mods(id, mods, actions, self.config)?;
|
||||
execute_mods(id, mods, actions, self.config).await?;
|
||||
}
|
||||
|
||||
// Delete all incoming ‘X-Spam-’ headers not returned by SpamAssassin to
|
||||
// get rid of foreign ‘X-Spam-Flag’ etc. headers.
|
||||
let deletions = self.original.keys()
|
||||
let deletions = self
|
||||
.original
|
||||
.keys()
|
||||
.filter(|n| is_spam_assassin_header(n) && !self.processed.contains(n))
|
||||
.map(|name| HeaderMod::Delete { name })
|
||||
.collect::<Vec<_>>();
|
||||
execute_mods(id, deletions.iter(), actions, self.config)
|
||||
execute_mods(id, deletions.iter(), actions, self.config).await
|
||||
}
|
||||
|
||||
pub fn rewrite_rewrite_headers(
|
||||
pub async fn rewrite_rewrite_headers(
|
||||
&self,
|
||||
id: &str,
|
||||
actions: &impl ActionContext,
|
||||
) -> milter::Result<()> {
|
||||
execute_mods(id, self.rewrite_mods.iter(), actions, self.config)
|
||||
actions: &impl ContextActions,
|
||||
) -> Result<()> {
|
||||
execute_mods(id, self.rewrite_mods.iter(), actions, self.config).await
|
||||
}
|
||||
|
||||
pub fn rewrite_report_headers(
|
||||
pub async fn rewrite_report_headers(
|
||||
&self,
|
||||
id: &str,
|
||||
actions: &impl ActionContext,
|
||||
) -> milter::Result<()> {
|
||||
execute_mods(id, self.report_mods.iter(), actions, self.config)
|
||||
actions: &impl ContextActions,
|
||||
) -> Result<()> {
|
||||
execute_mods(id, self.report_mods.iter(), actions, self.config).await
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_mods<'a, I>(
|
||||
async fn execute_mods<'a, I>(
|
||||
id: &str,
|
||||
mods: I,
|
||||
actions: &impl ActionContext,
|
||||
actions: &impl ContextActions,
|
||||
config: &Config,
|
||||
) -> milter::Result<()>
|
||||
) -> Result<()>
|
||||
where
|
||||
I: IntoIterator<Item = &'a HeaderMod<'a>>,
|
||||
{
|
||||
|
@ -284,30 +287,30 @@ where
|
|||
verbose!(config, "{}: rewriting header: {} [dry run, not done]", id, m);
|
||||
} else {
|
||||
verbose!(config, "{}: rewriting header: {}", id, m);
|
||||
m.execute(actions)?;
|
||||
m.execute(actions).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn replace_body(
|
||||
pub async fn replace_body(
|
||||
id: &str,
|
||||
body: &[u8],
|
||||
actions: &impl ActionContext,
|
||||
actions: &impl ContextActions,
|
||||
config: &Config,
|
||||
) -> milter::Result<()> {
|
||||
) -> Result<()> {
|
||||
if config.dry_run() {
|
||||
verbose!(config, "{}: replacing message body [dry run, not done]", id);
|
||||
} else {
|
||||
verbose!(config, "{}: replacing message body", id);
|
||||
actions.append_body_chunk(body)?;
|
||||
actions.replace_body(body).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A header rewriting modification operation. These are intended to operate
|
||||
/// only on the first instance of headers occurring multiple times.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
enum HeaderMod<'a> {
|
||||
Add { name: &'a str, value: &'a str, prepend: bool },
|
||||
Replace { name: &'a str, value: &'a str, prepend: bool },
|
||||
|
@ -316,39 +319,41 @@ enum HeaderMod<'a> {
|
|||
}
|
||||
|
||||
impl HeaderMod<'_> {
|
||||
fn execute(&self, actions: &impl ActionContext) -> milter::Result<()> {
|
||||
use HeaderMod::*;
|
||||
|
||||
async fn execute(&self, actions: &impl ContextActions) -> Result<()> {
|
||||
// The milter library is smart enough to treat the name in a
|
||||
// case-insensitive manner, eg ‘Subject’ may replace ‘sUbject’.
|
||||
match *self {
|
||||
Add { name, value, prepend } => add_header(actions, name, value, prepend),
|
||||
Replace { name, value, prepend } => {
|
||||
delete_header(actions, name)?;
|
||||
add_header(actions, name, value, prepend)?;
|
||||
Ok(())
|
||||
Self::Add { name, value, prepend } => add_header(actions, name, value, prepend).await?,
|
||||
Self::Replace { name, value, prepend } => {
|
||||
delete_header(actions, name).await?;
|
||||
add_header(actions, name, value, prepend).await?;
|
||||
}
|
||||
Modify { name, value } => actions.replace_header(name, 1, Some(&ensure_lf(value))),
|
||||
Delete { name } => delete_header(actions, name),
|
||||
Self::Modify { name, value } => {
|
||||
actions.change_header(name, 1, Some(ensure_lf(value))).await?;
|
||||
}
|
||||
Self::Delete { name } => delete_header(actions, name).await?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn add_header(
|
||||
actions: &impl ActionContext,
|
||||
async fn add_header(
|
||||
actions: &impl ContextActions,
|
||||
name: &str,
|
||||
value: &str,
|
||||
prepend: bool,
|
||||
) -> milter::Result<()> {
|
||||
) -> Result<()> {
|
||||
if prepend {
|
||||
actions.insert_header(0, name, &ensure_lf(value))
|
||||
actions.insert_header(0, name, ensure_lf(value)).await?;
|
||||
} else {
|
||||
actions.add_header(name, &ensure_lf(value))
|
||||
actions.add_header(name, ensure_lf(value)).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_header(actions: &impl ActionContext, name: &str) -> milter::Result<()> {
|
||||
actions.replace_header(name, 1, None)
|
||||
async fn delete_header(actions: &impl ContextActions, name: &str) -> Result<()> {
|
||||
actions.change_header(name, 1, None::<CString>).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Display for HeaderMod<'_> {
|
||||
|
@ -454,7 +459,7 @@ mod tests {
|
|||
#[test]
|
||||
fn header_rewriter_flags_spam() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(String::from("x-spam-flag"), String::from(" no"));
|
||||
headers.insert("x-spam-flag".into(), " no".into());
|
||||
let config = Default::default();
|
||||
|
||||
let mut rewriter = HeaderRewriter::new(headers, &config);
|
||||
|
@ -487,10 +492,10 @@ mod tests {
|
|||
#[test]
|
||||
fn header_rewriter_adds_and_replaces_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(String::from("x-spam-level"), String::from(" ***"));
|
||||
headers.insert(String::from("subject"), String::from(" original"));
|
||||
headers.insert(String::from("x-spam-prev-subject"), String::from(" very original"));
|
||||
headers.insert(String::from("to"), String::from(" recipient@gluet.ch"));
|
||||
headers.insert("x-spam-level".into(), " ***".into());
|
||||
headers.insert("subject".into(), " original".into());
|
||||
headers.insert("x-spam-prev-subject".into(), " very original".into());
|
||||
headers.insert("to".into(), " recipient@gluet.ch".into());
|
||||
let config = Default::default();
|
||||
|
||||
let mut rewriter = HeaderRewriter::new(headers, &config);
|
||||
|
|
31
src/error.rs
31
src/error.rs
|
@ -1,3 +1,4 @@
|
|||
use indymilter::{ActionError, SmtpReplyError};
|
||||
use std::{
|
||||
error,
|
||||
fmt::{self, Display, Formatter},
|
||||
|
@ -6,9 +7,11 @@ use std::{
|
|||
|
||||
pub type Result<T> = result::Result<T, Error>;
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum Error {
|
||||
ParseEmail,
|
||||
SmtpReply,
|
||||
Action,
|
||||
// For our purposes it is enough to record just the error message of I/O
|
||||
// errors, no need to keep the `io::Error` itself around.
|
||||
Io(String),
|
||||
|
@ -16,25 +19,31 @@ pub enum Error {
|
|||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
use Error::*;
|
||||
|
||||
match self {
|
||||
ParseEmail => write!(f, "failed to parse email"),
|
||||
Io(msg) => msg.fmt(f),
|
||||
Self::ParseEmail => write!(f, "failed to parse email"),
|
||||
Self::SmtpReply => write!(f, "could not configure SMTP error reply"),
|
||||
Self::Action => write!(f, "could not execute milter context action"),
|
||||
Self::Io(msg) => msg.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl error::Error for Error {}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Error::Io(error.to_string()) // just record the error message
|
||||
impl From<SmtpReplyError> for Error {
|
||||
fn from(_: SmtpReplyError) -> Self {
|
||||
Self::SmtpReply
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for milter::Error {
|
||||
fn from(error: Error) -> Self {
|
||||
milter::Error::Custom(error.into())
|
||||
impl From<ActionError> for Error {
|
||||
fn from(_: ActionError) -> Self {
|
||||
Self::Action
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Io(error.to_string()) // just record the error message
|
||||
}
|
||||
}
|
||||
|
|
56
src/lib.rs
56
src/lib.rs
|
@ -6,11 +6,6 @@ macro_rules! verbose {
|
|||
::std::eprintln!($($arg)*);
|
||||
}
|
||||
};
|
||||
($($arg:tt)*) => {
|
||||
if $crate::config::get().verbose() {
|
||||
::std::eprintln!($($arg)*);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
mod callbacks;
|
||||
|
@ -20,9 +15,9 @@ mod config;
|
|||
mod email;
|
||||
mod error;
|
||||
|
||||
use crate::callbacks::*;
|
||||
pub use crate::config::{Config, ConfigBuilder, SynthRelayPosition};
|
||||
use milter::Milter;
|
||||
use indymilter::IntoListener;
|
||||
use std::{future::Future, io};
|
||||
|
||||
/// The name of the SpamAssassin Milter application.
|
||||
pub const MILTER_NAME: &str = "SpamAssassin Milter";
|
||||
|
@ -33,46 +28,35 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|||
/// Starts SpamAssassin Milter listening on the given socket using the supplied
|
||||
/// configuration.
|
||||
///
|
||||
/// This is a blocking call.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If execution of the milter fails, an error variant of type `milter::Error`
|
||||
/// is returned.
|
||||
/// If execution of the milter fails, an error is returned.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use spamassassin_milter::Config;
|
||||
/// ```
|
||||
/// # async fn f() -> std::io::Result<()> {
|
||||
/// use std::process;
|
||||
/// use tokio::{net::TcpListener, signal};
|
||||
///
|
||||
/// let socket = "inet:3000@localhost";
|
||||
/// let config = Config::builder().build();
|
||||
/// let listener = TcpListener::bind("127.0.0.1:3000").await?;
|
||||
/// let config = Default::default();
|
||||
/// let shutdown = signal::ctrl_c();
|
||||
///
|
||||
/// if let Err(e) = spamassassin_milter::run(socket, config) {
|
||||
/// if let Err(e) = spamassassin_milter::run(listener, config, shutdown).await {
|
||||
/// eprintln!("failed to run spamassassin-milter: {}", e);
|
||||
/// process::exit(1);
|
||||
/// }
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn run(socket: &str, config: Config) -> milter::Result<()> {
|
||||
milter::set_debug_level(config.milter_debug_level());
|
||||
pub async fn run(
|
||||
listener: impl IntoListener,
|
||||
config: Config,
|
||||
shutdown: impl Future,
|
||||
) -> io::Result<()> {
|
||||
let callbacks = callbacks::make_callbacks(config);
|
||||
let config = Default::default();
|
||||
|
||||
config::init(config);
|
||||
|
||||
Milter::new(socket)
|
||||
.name(MILTER_NAME)
|
||||
.on_negotiate(negotiate_callback)
|
||||
.on_connect(connect_callback)
|
||||
.on_helo(helo_callback)
|
||||
.on_mail(mail_callback)
|
||||
.on_rcpt(rcpt_callback)
|
||||
.on_data(data_callback)
|
||||
.on_header(header_callback)
|
||||
.on_eoh(eoh_callback)
|
||||
.on_body(body_callback)
|
||||
.on_eom(eom_callback)
|
||||
.on_abort(abort_callback)
|
||||
.on_close(close_callback)
|
||||
.remove_socket(true)
|
||||
.run()
|
||||
indymilter::run(listener, callbacks, config, shutdown).await
|
||||
}
|
||||
|
|
283
src/main.rs
283
src/main.rs
|
@ -1,11 +1,20 @@
|
|||
use clap::{App, Arg, ArgMatches, Error, ErrorKind, Result};
|
||||
use spamassassin_milter::Config;
|
||||
use std::{net::IpAddr, process};
|
||||
use clap::{Arg, Command, ErrorKind};
|
||||
use futures::stream::StreamExt;
|
||||
use indymilter::Listener;
|
||||
use signal_hook::consts::{SIGINT, SIGTERM};
|
||||
use signal_hook_tokio::{Handle, Signals};
|
||||
use spamassassin_milter::{Config, MILTER_NAME, VERSION};
|
||||
use std::{net::IpAddr, os::unix::fs::FileTypeExt, path::Path, process, str::FromStr};
|
||||
use tokio::{
|
||||
fs,
|
||||
net::{TcpListener, UnixListener},
|
||||
sync::oneshot,
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
const ARG_AUTH_UNTRUSTED: &str = "AUTH_UNTRUSTED";
|
||||
const ARG_DRY_RUN: &str = "DRY_RUN";
|
||||
const ARG_MAX_MESSAGE_SIZE: &str = "MAX_MESSAGE_SIZE";
|
||||
const ARG_MILTER_DEBUG_LEVEL: &str = "MILTER_DEBUG_LEVEL";
|
||||
const ARG_PRESERVE_BODY: &str = "PRESERVE_BODY";
|
||||
const ARG_PRESERVE_HEADERS: &str = "PRESERVE_HEADERS";
|
||||
const ARG_REJECT_SPAM: &str = "REJECT_SPAM";
|
||||
|
@ -18,93 +27,129 @@ const ARG_VERBOSE: &str = "VERBOSE";
|
|||
const ARG_SOCKET: &str = "SOCKET";
|
||||
const ARG_SPAMC_ARGS: &str = "SPAMC_ARGS";
|
||||
|
||||
fn main() {
|
||||
use spamassassin_milter::{MILTER_NAME, VERSION};
|
||||
|
||||
let matches = App::new(MILTER_NAME)
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let command = Command::new(MILTER_NAME)
|
||||
.version(VERSION)
|
||||
.arg(Arg::with_name(ARG_AUTH_UNTRUSTED)
|
||||
.short("a")
|
||||
.arg(Arg::new(ARG_AUTH_UNTRUSTED)
|
||||
.short('a')
|
||||
.long("auth-untrusted")
|
||||
.help("Treat authenticated senders as untrusted"))
|
||||
.arg(Arg::with_name(ARG_DRY_RUN)
|
||||
.short("n")
|
||||
.arg(Arg::new(ARG_DRY_RUN)
|
||||
.short('n')
|
||||
.long("dry-run")
|
||||
.help("Process messages without applying any changes"))
|
||||
.arg(Arg::with_name(ARG_MAX_MESSAGE_SIZE)
|
||||
.short("s")
|
||||
.help("Process messages without applying changes"))
|
||||
.arg(Arg::new(ARG_MAX_MESSAGE_SIZE)
|
||||
.short('s')
|
||||
.long("max-message-size")
|
||||
.value_name("BYTES")
|
||||
.help("Maximum message size to process"))
|
||||
.arg(Arg::with_name(ARG_MILTER_DEBUG_LEVEL)
|
||||
.long("milter-debug-level")
|
||||
.value_name("LEVEL")
|
||||
.possible_values(&["0", "1", "2", "3", "4", "5", "6"])
|
||||
.help("Set the milter library debug level")
|
||||
.hidden(true) // not documented for now
|
||||
.hide_possible_values(true))
|
||||
.arg(Arg::with_name(ARG_PRESERVE_BODY)
|
||||
.short("B")
|
||||
.arg(Arg::new(ARG_PRESERVE_BODY)
|
||||
.short('B')
|
||||
.long("preserve-body")
|
||||
.help("Suppress rewriting of message body"))
|
||||
.arg(Arg::with_name(ARG_PRESERVE_HEADERS)
|
||||
.short("H")
|
||||
.arg(Arg::new(ARG_PRESERVE_HEADERS)
|
||||
.short('H')
|
||||
.long("preserve-headers")
|
||||
.help("Suppress rewriting of Subject/From/To headers"))
|
||||
.arg(Arg::with_name(ARG_REJECT_SPAM)
|
||||
.short("r")
|
||||
.arg(Arg::new(ARG_REJECT_SPAM)
|
||||
.short('r')
|
||||
.long("reject-spam")
|
||||
.help("Reject messages flagged as spam"))
|
||||
.arg(Arg::with_name(ARG_REPLY_CODE)
|
||||
.short("C")
|
||||
.arg(Arg::new(ARG_REPLY_CODE)
|
||||
.short('C')
|
||||
.long("reply-code")
|
||||
.value_name("CODE")
|
||||
.help("Reply code when rejecting messages"))
|
||||
.arg(Arg::with_name(ARG_REPLY_STATUS_CODE)
|
||||
.short("S")
|
||||
.arg(Arg::new(ARG_REPLY_STATUS_CODE)
|
||||
.short('S')
|
||||
.long("reply-status-code")
|
||||
.value_name("CODE")
|
||||
.help("Status code when rejecting messages"))
|
||||
.arg(Arg::with_name(ARG_REPLY_TEXT)
|
||||
.short("R")
|
||||
.arg(Arg::new(ARG_REPLY_TEXT)
|
||||
.short('R')
|
||||
.long("reply-text")
|
||||
.value_name("MSG")
|
||||
.help("Reply text when rejecting messages"))
|
||||
.arg(Arg::with_name(ARG_SYNTH_RELAY)
|
||||
.arg(Arg::new(ARG_SYNTH_RELAY)
|
||||
.long("synth-relay")
|
||||
.value_name("POS")
|
||||
.help("Synthesize relay header after position"))
|
||||
.arg(Arg::with_name(ARG_TRUSTED_NETWORKS)
|
||||
.short("t")
|
||||
.arg(Arg::new(ARG_TRUSTED_NETWORKS)
|
||||
.short('t')
|
||||
.long("trusted-networks")
|
||||
.value_name("NETS")
|
||||
.use_delimiter(true)
|
||||
.use_value_delimiter(true)
|
||||
.help("Trust connections from these networks"))
|
||||
.arg(Arg::with_name(ARG_VERBOSE)
|
||||
.short("v")
|
||||
.arg(Arg::new(ARG_VERBOSE)
|
||||
.short('v')
|
||||
.long("verbose")
|
||||
.help("Enable verbose operation logging"))
|
||||
.arg(Arg::with_name(ARG_SOCKET)
|
||||
.arg(Arg::new(ARG_SOCKET)
|
||||
.required(true)
|
||||
.help("Listening socket of the milter"))
|
||||
.arg(Arg::with_name(ARG_SPAMC_ARGS)
|
||||
.arg(Arg::new(ARG_SPAMC_ARGS)
|
||||
.last(true)
|
||||
.multiple(true)
|
||||
.help("Additional arguments to pass to spamc"))
|
||||
.get_matches();
|
||||
.multiple_occurrences(true)
|
||||
.help("Additional arguments to pass to spamc"));
|
||||
|
||||
let socket = matches.value_of(ARG_SOCKET).unwrap();
|
||||
let config = match build_config(&matches) {
|
||||
let (socket, config) = match build_config(command) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
e.exit();
|
||||
}
|
||||
};
|
||||
|
||||
let (shutdown_tx, shutdown) = oneshot::channel();
|
||||
|
||||
let signals = Signals::new(&[SIGTERM, SIGINT]).expect("failed to install signal handler");
|
||||
let signals_handle = signals.handle();
|
||||
let signals_task = spawn_signals_task(signals, shutdown_tx);
|
||||
|
||||
let addr;
|
||||
let mut socket_path = None;
|
||||
let listener = match socket {
|
||||
Socket::Inet(socket) => {
|
||||
let listener = match TcpListener::bind(socket).await {
|
||||
Ok(listener) => listener,
|
||||
Err(e) => {
|
||||
eprintln!("error: could not bind TCP socket: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
Listener::Tcp(listener)
|
||||
}
|
||||
Socket::Unix(socket) => {
|
||||
// Before creating the socket file, try removing any existing socket
|
||||
// at the target path. This is to clear out a leftover file from a
|
||||
// previous, aborted execution.
|
||||
try_remove_socket(&socket).await;
|
||||
|
||||
let listener = match UnixListener::bind(socket) {
|
||||
Ok(listener) => listener,
|
||||
Err(e) => {
|
||||
eprintln!("error: could not create UNIX domain socket: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Remember the socket file path, and delete it on shutdown.
|
||||
addr = listener.local_addr().unwrap();
|
||||
socket_path = addr.as_pathname();
|
||||
|
||||
Listener::Unix(listener)
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("{} {} starting", MILTER_NAME, VERSION);
|
||||
|
||||
match spamassassin_milter::run(socket, config) {
|
||||
Ok(_) => {
|
||||
let result = spamassassin_milter::run(listener, config, shutdown).await;
|
||||
|
||||
cleanup(signals_handle, signals_task, socket_path).await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
eprintln!("{} {} shut down", MILTER_NAME, VERSION);
|
||||
}
|
||||
Err(e) => {
|
||||
|
@ -114,18 +159,50 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
fn build_config(matches: &ArgMatches<'_>) -> Result<Config> {
|
||||
enum Socket {
|
||||
Inet(String),
|
||||
Unix(String),
|
||||
}
|
||||
|
||||
impl FromStr for Socket {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
if let Some(s) = s.strip_prefix("inet:") {
|
||||
Ok(Self::Inet(s.into()))
|
||||
} else if let Some(s) = s.strip_prefix("unix:") {
|
||||
Ok(Self::Unix(s.into()))
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_config(mut command: Command) -> clap::Result<(Socket, Config)> {
|
||||
let matches = command.get_matches_mut();
|
||||
|
||||
let socket = matches.value_of(ARG_SOCKET).unwrap();
|
||||
let socket = match socket.parse() {
|
||||
Ok(socket) => socket,
|
||||
Err(()) => {
|
||||
return Err(command.error(
|
||||
ErrorKind::InvalidValue,
|
||||
format!("Invalid value for socket: \"{}\"", socket),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let mut config = Config::builder();
|
||||
|
||||
if let Some(bytes) = matches.value_of(ARG_MAX_MESSAGE_SIZE) {
|
||||
match bytes.parse() {
|
||||
Ok(bytes) => {
|
||||
config.max_message_size(bytes);
|
||||
config = config.max_message_size(bytes);
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(Error::with_description(
|
||||
&format!("Invalid value for max message size: \"{}\"", bytes),
|
||||
return Err(command.error(
|
||||
ErrorKind::InvalidValue,
|
||||
format!("Invalid value for max message size: \"{}\"", bytes),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
@ -134,30 +211,33 @@ fn build_config(matches: &ArgMatches<'_>) -> Result<Config> {
|
|||
if let Some(pos) = matches.value_of(ARG_SYNTH_RELAY) {
|
||||
match pos.parse() {
|
||||
Ok(pos) => {
|
||||
config.synth_relay(pos);
|
||||
config = config.synth_relay(pos);
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(Error::with_description(
|
||||
&format!("Invalid value for synthesized relay header position: \"{}\"", pos),
|
||||
return Err(command.error(
|
||||
ErrorKind::InvalidValue,
|
||||
format!("Invalid value for synthesized relay header position: \"{}\"", pos),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(nets) = matches.values_of(ARG_TRUSTED_NETWORKS) {
|
||||
config.use_trusted_networks(true);
|
||||
config = config.use_trusted_networks(true);
|
||||
|
||||
for net in nets.filter(|n| !n.is_empty()) {
|
||||
// Both `ipnet::IpNet` and `std::net::IpAddr` inputs are supported.
|
||||
match net.parse().or_else(|_| net.parse::<IpAddr>().map(From::from)) {
|
||||
match net
|
||||
.parse()
|
||||
.or_else(|_| net.parse::<IpAddr>().map(From::from))
|
||||
{
|
||||
Ok(net) => {
|
||||
config.trusted_network(net);
|
||||
config = config.trusted_network(net);
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(Error::with_description(
|
||||
&format!("Invalid value for trusted network address: \"{}\"", net),
|
||||
return Err(command.error(
|
||||
ErrorKind::InvalidValue,
|
||||
format!("Invalid value for trusted network address: \"{}\"", net),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
@ -166,66 +246,101 @@ fn build_config(matches: &ArgMatches<'_>) -> Result<Config> {
|
|||
|
||||
let reply_code = matches.value_of(ARG_REPLY_CODE);
|
||||
let reply_status_code = matches.value_of(ARG_REPLY_STATUS_CODE);
|
||||
validate_reply_codes(reply_code, reply_status_code)?;
|
||||
validate_reply_codes(&mut command, reply_code, reply_status_code)?;
|
||||
|
||||
if matches.is_present(ARG_AUTH_UNTRUSTED) {
|
||||
config.auth_untrusted(true);
|
||||
config = config.auth_untrusted(true);
|
||||
}
|
||||
if matches.is_present(ARG_DRY_RUN) {
|
||||
config.dry_run(true);
|
||||
config = config.dry_run(true);
|
||||
}
|
||||
if matches.is_present(ARG_PRESERVE_BODY) {
|
||||
config.preserve_body(true);
|
||||
config = config.preserve_body(true);
|
||||
}
|
||||
if matches.is_present(ARG_PRESERVE_HEADERS) {
|
||||
config.preserve_headers(true);
|
||||
config = config.preserve_headers(true);
|
||||
}
|
||||
if matches.is_present(ARG_REJECT_SPAM) {
|
||||
config.reject_spam(true);
|
||||
config = config.reject_spam(true);
|
||||
}
|
||||
if matches.is_present(ARG_VERBOSE) {
|
||||
config.verbose(true);
|
||||
config = config.verbose(true);
|
||||
}
|
||||
if let Some(code) = reply_code {
|
||||
config.reply_code(code.to_owned());
|
||||
config = config.reply_code(code.to_owned());
|
||||
}
|
||||
if let Some(code) = reply_status_code {
|
||||
config.reply_status_code(code.to_owned());
|
||||
config = config.reply_status_code(code.to_owned());
|
||||
}
|
||||
if let Some(msg) = matches.value_of(ARG_REPLY_TEXT) {
|
||||
config.reply_text(msg.to_owned());
|
||||
}
|
||||
if let Some(level) = matches.value_of(ARG_MILTER_DEBUG_LEVEL) {
|
||||
config.milter_debug_level(level.parse().unwrap());
|
||||
config = config.reply_text(msg.to_owned());
|
||||
}
|
||||
if let Some(spamc_args) = matches.values_of(ARG_SPAMC_ARGS) {
|
||||
config.spamc_args(spamc_args);
|
||||
config = config.spamc_args(spamc_args);
|
||||
};
|
||||
|
||||
Ok(config.build())
|
||||
Ok((socket, config.build()))
|
||||
}
|
||||
|
||||
fn validate_reply_codes(reply_code: Option<&str>, reply_status_code: Option<&str>) -> Result<()> {
|
||||
fn validate_reply_codes(
|
||||
command: &mut Command,
|
||||
reply_code: Option<&str>,
|
||||
reply_status_code: Option<&str>,
|
||||
) -> clap::Result<()> {
|
||||
match (reply_code, reply_status_code) {
|
||||
(Some(c1), Some(c2))
|
||||
if !((c1.starts_with('4') || c1.starts_with('5')) && c2.starts_with(&c1[..1])) =>
|
||||
{
|
||||
Err(Error::with_description(
|
||||
&format!(
|
||||
Err(command.error(
|
||||
ErrorKind::InvalidValue,
|
||||
format!(
|
||||
"Invalid or incompatible values for reply code and status code: \"{}\", \"{}\"",
|
||||
c1, c2
|
||||
),
|
||||
ErrorKind::InvalidValue,
|
||||
))
|
||||
}
|
||||
(Some(c), None) if !c.starts_with('5') => Err(Error::with_description(
|
||||
&format!("Invalid value for reply code (5XX): \"{}\"", c),
|
||||
(Some(c), None) if !c.starts_with('5') => Err(command.error(
|
||||
ErrorKind::InvalidValue,
|
||||
format!("Invalid value for reply code (5XX): \"{}\"", c),
|
||||
)),
|
||||
(None, Some(c)) if !c.starts_with('5') => Err(Error::with_description(
|
||||
&format!("Invalid value for reply status code (5.X.X): \"{}\"", c),
|
||||
(None, Some(c)) if !c.starts_with('5') => Err(command.error(
|
||||
ErrorKind::InvalidValue,
|
||||
format!("Invalid value for reply status code (5.X.X): \"{}\"", c),
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_signals_task(
|
||||
mut signals: Signals,
|
||||
shutdown_milter: oneshot::Sender<()>,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
while let Some(signal) = signals.next().await {
|
||||
match signal {
|
||||
SIGINT | SIGTERM => {
|
||||
let _ = shutdown_milter.send(());
|
||||
break;
|
||||
}
|
||||
_ => panic!("unexpected signal"),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn cleanup(signals_handle: Handle, signals_task: JoinHandle<()>, socket_path: Option<&Path>) {
|
||||
signals_handle.close();
|
||||
signals_task.await.expect("signal handler task failed");
|
||||
|
||||
if let Some(p) = socket_path {
|
||||
try_remove_socket(p).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_remove_socket(path: impl AsRef<Path>) {
|
||||
if let Ok(metadata) = fs::metadata(&path).await {
|
||||
if metadata.file_type().is_socket() {
|
||||
let _ = fs::remove_file(path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
-- An authenticated sender is accepted, the message is not processed.
|
||||
|
||||
local conn = mt.connect("inet:3333@localhost")
|
||||
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
mod common;
|
||||
|
||||
pub use common::*;
|
||||
use spamassassin_milter::*;
|
||||
|
||||
#[test]
|
||||
fn authenticated_sender() {
|
||||
let config = Default::default();
|
||||
#[tokio::test]
|
||||
async fn authenticated_sender() {
|
||||
let milter = SpamAssassinMilter::spawn(LOCALHOST, Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let miltertest = spawn_miltertest_runner(file!());
|
||||
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
|
||||
|
||||
run("inet:3333@localhost", config).expect("milter execution failed");
|
||||
milter.shutdown().await.unwrap();
|
||||
|
||||
let exit_code = miltertest.join().expect("panic in miltertest runner");
|
||||
assert!(exit_code.success(), "miltertest returned error exit code");
|
||||
assert!(exit_code.success());
|
||||
}
|
||||
|
|
|
@ -1,24 +1,32 @@
|
|||
use spamassassin_milter::ConfigBuilder;
|
||||
use spamassassin_milter::{Config, ConfigBuilder};
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
io::{ErrorKind, Read, Write},
|
||||
net::{Ipv6Addr, Shutdown, SocketAddr, TcpListener},
|
||||
io::{self, ErrorKind},
|
||||
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
path::PathBuf,
|
||||
process::{Command, ExitStatus},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
process::ExitStatus,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::{TcpListener, ToSocketAddrs},
|
||||
process::Command,
|
||||
sync::oneshot,
|
||||
task::JoinHandle,
|
||||
time,
|
||||
};
|
||||
|
||||
pub const LOCALHOST: (Ipv4Addr, u16) = (Ipv4Addr::LOCALHOST, 0);
|
||||
|
||||
/// Configures the builder for integration testing with `spamc`. Most
|
||||
/// importantly, this isolates `spamc` from any configuration file
|
||||
/// `/etc/spamassassin/spamc.conf` present on the host, as this configuration is
|
||||
/// read by default and may break the integration tests.
|
||||
pub fn configure_spamc(mut builder: ConfigBuilder) -> ConfigBuilder {
|
||||
pub fn configure_spamc(builder: ConfigBuilder) -> ConfigBuilder {
|
||||
// Note: Must use `-F` instead of `--config` due to a bug in `spamc`.
|
||||
// `--no-safe-fallback` prevents connection attempts from failing silently,
|
||||
// and `--log-to-stderr` avoids polluting syslog with test output.
|
||||
builder.spamc_args(&["-F", "/dev/null", "--no-safe-fallback", "--log-to-stderr"]);
|
||||
builder
|
||||
builder.spamc_args(&["-F", "/dev/null", "--no-safe-fallback", "--log-to-stderr"])
|
||||
}
|
||||
|
||||
pub const SPAMD_PORT: u16 = 3783; // mock port
|
||||
|
@ -28,43 +36,30 @@ pub type HamOrSpam = Result<String, String>;
|
|||
/// Spawns a mock `spamd` server that echoes what it is sent after applying
|
||||
/// transformation `f` to the message content and mock-classifying it as ham or
|
||||
/// spam.
|
||||
pub fn spawn_mock_spamd_server<F>(port: u16, f: F) -> JoinHandle<()>
|
||||
pub async fn spawn_mock_spamd_server<F>(port: u16, f: F) -> io::Result<JoinHandle<io::Result<()>>>
|
||||
where
|
||||
F: Fn(String) -> HamOrSpam + Send + 'static,
|
||||
{
|
||||
let socket_addr = SocketAddr::new(Ipv6Addr::LOCALHOST.into(), port);
|
||||
let timeout = Duration::from_secs(15);
|
||||
|
||||
thread::spawn(move || {
|
||||
let listener = TcpListener::bind(socket_addr).unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
|
||||
let now = Instant::now();
|
||||
let socket_addr = (Ipv6Addr::LOCALHOST, port);
|
||||
let listener = TcpListener::bind(socket_addr).await?;
|
||||
|
||||
Ok(tokio::spawn(async move {
|
||||
// This server expects and handles only a single connection, so that we
|
||||
// can `join` this thread in the tests and detect panics. A panic can be
|
||||
// triggered both in the handling code as well as due to the timeout.
|
||||
loop {
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
let mut buf = Vec::new();
|
||||
stream.read_to_end(&mut buf).unwrap();
|
||||
stream.write_all(process_message(buf, &f).as_bytes()).unwrap();
|
||||
stream.shutdown(Shutdown::Write).unwrap();
|
||||
break;
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
if now.elapsed() > timeout {
|
||||
panic!("mock spamd server timed out waiting for a connection");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("mock spamd server could not open connection: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// can `join` this task in the tests and detect errors and panics.
|
||||
let (mut stream, _) = time::timeout(Duration::from_secs(10), listener.accept())
|
||||
.await
|
||||
.map_err(|e| io::Error::new(ErrorKind::Other, e))??;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
|
||||
stream.read_to_end(&mut buf).await?;
|
||||
|
||||
let msg = process_message(buf, &f);
|
||||
|
||||
stream.write_all(msg.as_bytes()).await?;
|
||||
|
||||
Ok(())
|
||||
}))
|
||||
}
|
||||
|
||||
// The SpamAssassin client/server protocol is here reverse-engineered in a very
|
||||
|
@ -76,7 +71,7 @@ const SPAMD_PROTOCOL_OK: &str = "SPAMD/1.1 0 EX_OK";
|
|||
|
||||
fn process_message<F>(buf: Vec<u8>, f: &F) -> String
|
||||
where
|
||||
F: Fn(String) -> HamOrSpam,
|
||||
F: Fn(String) -> HamOrSpam + Send + 'static,
|
||||
{
|
||||
let mut msg = String::from_utf8(buf).unwrap();
|
||||
|
||||
|
@ -104,38 +99,55 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
const MILTERTEST_PROGRAM: &str = "miltertest";
|
||||
pub struct SpamAssassinMilter {
|
||||
milter_handle: JoinHandle<io::Result<()>>,
|
||||
shutdown: oneshot::Sender<()>,
|
||||
addr: SocketAddr,
|
||||
}
|
||||
|
||||
pub fn spawn_miltertest_runner(test_file_name: &str) -> JoinHandle<ExitStatus> {
|
||||
// This thread is just for safety, in case the miltertest runner thread
|
||||
// below never manages to shut down the milter.
|
||||
let _timeout_thread = thread::spawn(|| {
|
||||
thread::sleep(Duration::from_secs(20));
|
||||
impl SpamAssassinMilter {
|
||||
pub async fn spawn(addr: impl ToSocketAddrs, config: Config) -> io::Result<Self> {
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
|
||||
eprintln!("miltertest runner timed out");
|
||||
let addr = listener.local_addr()?;
|
||||
|
||||
milter::shutdown();
|
||||
});
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
|
||||
let milter = tokio::spawn(spamassassin_milter::run(listener, config, shutdown_rx));
|
||||
|
||||
Ok(Self {
|
||||
milter_handle: milter,
|
||||
shutdown: shutdown_tx,
|
||||
addr,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn addr(&self) -> SocketAddr {
|
||||
self.addr
|
||||
}
|
||||
|
||||
pub async fn shutdown(self) -> io::Result<()> {
|
||||
let _ = self.shutdown.send(());
|
||||
|
||||
self.milter_handle.await?
|
||||
}
|
||||
}
|
||||
|
||||
const MILTERTEST_PROGRAM: &str = "/usr/bin/miltertest";
|
||||
|
||||
pub async fn run_miltertest(test_file_name: &str, addr: SocketAddr) -> io::Result<ExitStatus> {
|
||||
let file_name = to_miltertest_file_name(test_file_name);
|
||||
let port = addr.port();
|
||||
|
||||
thread::spawn(move || {
|
||||
// Wait just a little while to give the milter time to start up.
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
let mut miltertest = Command::new(MILTERTEST_PROGRAM)
|
||||
// .arg("-vvv")
|
||||
.arg("-D")
|
||||
.arg(format!("port={}", port))
|
||||
.arg("-s")
|
||||
.arg(&file_name)
|
||||
.spawn()?;
|
||||
|
||||
let output = Command::new(MILTERTEST_PROGRAM)
|
||||
.arg("-s")
|
||||
.arg(&file_name)
|
||||
.output()
|
||||
.expect("miltertest execution failed");
|
||||
|
||||
print_output_stream("STDOUT", output.stdout);
|
||||
print_output_stream("STDERR", output.stderr);
|
||||
|
||||
milter::shutdown();
|
||||
|
||||
output.status
|
||||
})
|
||||
miltertest.wait().await
|
||||
}
|
||||
|
||||
fn to_miltertest_file_name(file_name: &str) -> OsString {
|
||||
|
@ -143,17 +155,3 @@ fn to_miltertest_file_name(file_name: &str) -> OsString {
|
|||
path.set_extension("lua");
|
||||
path.into_os_string()
|
||||
}
|
||||
|
||||
fn print_output_stream(name: &str, output: Vec<u8>) {
|
||||
if !output.is_empty() {
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
|
||||
eprintln!("{}:", name);
|
||||
|
||||
if output.ends_with('\n') {
|
||||
eprint!("{}", &output)
|
||||
} else {
|
||||
eprintln!("{}", &output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
-- ‘Happy path’ processing of an ordinary ham (not spam) message.
|
||||
|
||||
local conn = mt.connect("inet:3333@localhost")
|
||||
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
|
||||
|
|
|
@ -3,11 +3,11 @@ mod common;
|
|||
pub use common::*;
|
||||
use spamassassin_milter::*;
|
||||
|
||||
#[test]
|
||||
fn ham_message() {
|
||||
let mut builder = configure_spamc(Config::builder());
|
||||
builder.spamc_args(&[format!("--port={}", SPAMD_PORT)]);
|
||||
let config = builder.build();
|
||||
#[tokio::test]
|
||||
async fn ham_message() {
|
||||
let config = configure_spamc(Config::builder())
|
||||
.spamc_args(&[format!("--port={}", SPAMD_PORT)])
|
||||
.build();
|
||||
|
||||
let server = spawn_mock_spamd_server(SPAMD_PORT, |ham| {
|
||||
let mut ham = ham
|
||||
|
@ -22,14 +22,16 @@ fn ham_message() {
|
|||
);
|
||||
|
||||
Ok(ham)
|
||||
});
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let miltertest = spawn_miltertest_runner(file!());
|
||||
let milter = SpamAssassinMilter::spawn(LOCALHOST, config).await.unwrap();
|
||||
|
||||
run("inet:3333@localhost", config).expect("milter execution failed");
|
||||
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
|
||||
|
||||
let exit_code = miltertest.join().expect("panic in miltertest runner");
|
||||
assert!(exit_code.success(), "miltertest returned error exit code");
|
||||
milter.shutdown().await.unwrap();
|
||||
server.await.unwrap().unwrap();
|
||||
|
||||
server.join().expect("panic in mock spamd server");
|
||||
assert!(exit_code.success());
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
-- Live test against a real SpamAssassin server.
|
||||
|
||||
local conn = mt.connect("inet:3333@localhost")
|
||||
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
|
||||
|
|
|
@ -6,17 +6,18 @@ use spamassassin_milter::*;
|
|||
/// Runs a ‘live’ test against a real SpamAssassin server instance. This test is
|
||||
/// run on demand, as SpamAssassin will actually analyse the input, and do DNS
|
||||
/// queries etc.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn live() {
|
||||
#[tokio::test]
|
||||
async fn live() {
|
||||
// When no port is specified, `spamc` will try to connect to the default
|
||||
// `spamd` port 783 (see also `/etc/services`).
|
||||
let config = configure_spamc(Config::builder()).build();
|
||||
|
||||
let miltertest = spawn_miltertest_runner(file!());
|
||||
let milter = SpamAssassinMilter::spawn(LOCALHOST, config).await.unwrap();
|
||||
|
||||
run("inet:3333@localhost", config).expect("milter execution failed");
|
||||
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
|
||||
|
||||
let exit_code = miltertest.join().expect("panic in miltertest runner");
|
||||
assert!(exit_code.success(), "miltertest returned error exit code");
|
||||
milter.shutdown().await.unwrap();
|
||||
|
||||
assert!(exit_code.success());
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
-- 1) A connection from the loopback IP address is accepted.
|
||||
|
||||
local conn = mt.connect("inet:3333@localhost")
|
||||
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, nil, "127.0.0.1")
|
||||
|
@ -13,7 +13,7 @@ assert(err == nil, err)
|
|||
-- 2) A connection from an ‘unknown’ IP address (for example, from a UNIX
|
||||
-- domain socket) is also accepted.
|
||||
|
||||
local conn = mt.connect("inet:3333@localhost")
|
||||
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, nil, "unspec")
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
mod common;
|
||||
|
||||
pub use common::*;
|
||||
use spamassassin_milter::*;
|
||||
|
||||
#[test]
|
||||
fn loopback_connection() {
|
||||
let config = Default::default();
|
||||
#[tokio::test]
|
||||
async fn loopback_connection() {
|
||||
let milter = SpamAssassinMilter::spawn(LOCALHOST, Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let miltertest = spawn_miltertest_runner(file!());
|
||||
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
|
||||
|
||||
run("inet:3333@localhost", config).expect("milter execution failed");
|
||||
milter.shutdown().await.unwrap();
|
||||
|
||||
let exit_code = miltertest.join().expect("panic in miltertest runner");
|
||||
assert!(exit_code.success(), "miltertest returned error exit code");
|
||||
assert!(exit_code.success());
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
-- A spam message is rejected with an SMTP error reply.
|
||||
|
||||
local conn = mt.connect("inet:3333@localhost")
|
||||
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
|
||||
|
|
|
@ -3,26 +3,27 @@ mod common;
|
|||
pub use common::*;
|
||||
use spamassassin_milter::*;
|
||||
|
||||
#[test]
|
||||
fn reject_spam() {
|
||||
let mut builder = configure_spamc(Config::builder());
|
||||
builder
|
||||
#[tokio::test]
|
||||
async fn reject_spam() {
|
||||
let config = configure_spamc(Config::builder())
|
||||
.reject_spam(true)
|
||||
.reply_code("554".into())
|
||||
.reply_text("Not allowed!".into())
|
||||
.spamc_args(&[format!("--port={}", SPAMD_PORT)]);
|
||||
let config = builder.build();
|
||||
.spamc_args(&[format!("--port={}", SPAMD_PORT)])
|
||||
.build();
|
||||
|
||||
let server = spawn_mock_spamd_server(SPAMD_PORT, |spam| {
|
||||
Err(spam.replacen("\r\n\r\n", "\r\nX-Spam-Flag: YES\r\n\r\n", 1))
|
||||
});
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let miltertest = spawn_miltertest_runner(file!());
|
||||
let milter = SpamAssassinMilter::spawn(LOCALHOST, config).await.unwrap();
|
||||
|
||||
run("inet:3333@localhost", config).expect("milter execution failed");
|
||||
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
|
||||
|
||||
let exit_code = miltertest.join().expect("panic in miltertest runner");
|
||||
assert!(exit_code.success(), "miltertest returned error exit code");
|
||||
milter.shutdown().await.unwrap();
|
||||
server.await.unwrap().unwrap();
|
||||
|
||||
server.join().expect("panic in mock spamd server");
|
||||
assert!(exit_code.success());
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
-- reached, the rest is skipped (oversized messages are not processed by
|
||||
-- SpamAssassin, so it is futile to send the whole message in this case).
|
||||
|
||||
local conn = mt.connect("inet:3333@localhost")
|
||||
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
|
||||
|
|
|
@ -3,21 +3,21 @@ mod common;
|
|||
pub use common::*;
|
||||
use spamassassin_milter::*;
|
||||
|
||||
#[test]
|
||||
fn skip_oversized() {
|
||||
let mut builder = configure_spamc(Config::builder());
|
||||
builder
|
||||
#[tokio::test]
|
||||
async fn skip_oversized() {
|
||||
let config = configure_spamc(Config::builder())
|
||||
.max_message_size(512)
|
||||
.spamc_args(&[format!("--port={}", SPAMD_PORT)]);
|
||||
let config = builder.build();
|
||||
.spamc_args(&[format!("--port={}", SPAMD_PORT)])
|
||||
.build();
|
||||
|
||||
let server = spawn_mock_spamd_server(SPAMD_PORT, Ok);
|
||||
let miltertest = spawn_miltertest_runner(file!());
|
||||
let server = spawn_mock_spamd_server(SPAMD_PORT, Ok).await.unwrap();
|
||||
|
||||
run("inet:3333@localhost", config).expect("milter execution failed");
|
||||
let milter = SpamAssassinMilter::spawn(LOCALHOST, config).await.unwrap();
|
||||
|
||||
let exit_code = miltertest.join().expect("panic in miltertest runner");
|
||||
assert!(exit_code.success(), "miltertest returned error exit code");
|
||||
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
|
||||
|
||||
server.join().expect("panic in mock spamd server");
|
||||
milter.shutdown().await.unwrap();
|
||||
server.await.unwrap().unwrap();
|
||||
|
||||
assert!(exit_code.success());
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
-- ‘Happy path’ processing of a spam message.
|
||||
|
||||
local conn = mt.connect("inet:3333@localhost")
|
||||
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
|
||||
|
|
|
@ -3,11 +3,11 @@ mod common;
|
|||
pub use common::*;
|
||||
use spamassassin_milter::*;
|
||||
|
||||
#[test]
|
||||
fn spam_message() {
|
||||
let mut builder = configure_spamc(Config::builder());
|
||||
builder.spamc_args(&[format!("--port={}", SPAMD_PORT)]);
|
||||
let config = builder.build();
|
||||
#[tokio::test]
|
||||
async fn spam_message() {
|
||||
let config = configure_spamc(Config::builder())
|
||||
.spamc_args(&[format!("--port={}", SPAMD_PORT)])
|
||||
.build();
|
||||
|
||||
let server = spawn_mock_spamd_server(SPAMD_PORT, |spam| {
|
||||
let mut spam = spam
|
||||
|
@ -36,14 +36,16 @@ fn spam_message() {
|
|||
);
|
||||
|
||||
Err(spam)
|
||||
});
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let miltertest = spawn_miltertest_runner(file!());
|
||||
let milter = SpamAssassinMilter::spawn(LOCALHOST, config).await.unwrap();
|
||||
|
||||
run("inet:3333@localhost", config).expect("milter execution failed");
|
||||
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
|
||||
|
||||
let exit_code = miltertest.join().expect("panic in miltertest runner");
|
||||
assert!(exit_code.success(), "miltertest returned error exit code");
|
||||
milter.shutdown().await.unwrap();
|
||||
server.await.unwrap().unwrap();
|
||||
|
||||
server.join().expect("panic in mock spamd server");
|
||||
assert!(exit_code.success());
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
-- When no `spamd` server is available, `spamc` fails to connect.
|
||||
|
||||
local conn = mt.connect("inet:3333@localhost")
|
||||
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
|
||||
|
|
|
@ -3,16 +3,17 @@ mod common;
|
|||
pub use common::*;
|
||||
use spamassassin_milter::*;
|
||||
|
||||
#[test]
|
||||
fn spamc_connection_error() {
|
||||
let mut builder = configure_spamc(Config::builder());
|
||||
builder.spamc_args(&[format!("--port={}", SPAMD_PORT)]);
|
||||
let config = builder.build();
|
||||
#[tokio::test]
|
||||
async fn spamc_connection_error() {
|
||||
let config = configure_spamc(Config::builder())
|
||||
.spamc_args(&[format!("--port={}", SPAMD_PORT)])
|
||||
.build();
|
||||
|
||||
let miltertest = spawn_miltertest_runner(file!());
|
||||
let milter = SpamAssassinMilter::spawn(LOCALHOST, config).await.unwrap();
|
||||
|
||||
run("inet:3333@localhost", config).expect("milter execution failed");
|
||||
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
|
||||
|
||||
let exit_code = miltertest.join().expect("panic in miltertest runner");
|
||||
assert!(exit_code.success(), "miltertest returned error exit code");
|
||||
milter.shutdown().await.unwrap();
|
||||
|
||||
assert!(exit_code.success());
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
-- A connection from a trusted network is accepted.
|
||||
|
||||
local conn = mt.connect("inet:3333@localhost")
|
||||
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
|
||||
|
|
|
@ -3,16 +3,17 @@ mod common;
|
|||
pub use common::*;
|
||||
use spamassassin_milter::*;
|
||||
|
||||
#[test]
|
||||
fn trusted_network_connection() {
|
||||
let mut builder = Config::builder();
|
||||
builder.trusted_network("123.120.0.0/14".parse().unwrap());
|
||||
let config = builder.build();
|
||||
#[tokio::test]
|
||||
async fn trusted_network_connection() {
|
||||
let config = Config::builder()
|
||||
.trusted_network("123.120.0.0/14".parse().unwrap())
|
||||
.build();
|
||||
|
||||
let miltertest = spawn_miltertest_runner(file!());
|
||||
let milter = SpamAssassinMilter::spawn(LOCALHOST, config).await.unwrap();
|
||||
|
||||
run("inet:3333@localhost", config).expect("milter execution failed");
|
||||
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
|
||||
|
||||
let exit_code = miltertest.join().expect("panic in miltertest runner");
|
||||
assert!(exit_code.success(), "miltertest returned error exit code");
|
||||
milter.shutdown().await.unwrap();
|
||||
|
||||
assert!(exit_code.success());
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue