diff --git a/filtermail/.github/dependabot.yml b/filtermail/.github/dependabot.yml new file mode 100644 index 00000000..83fb5346 --- /dev/null +++ b/filtermail/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "monthly" + cooldown: + default-days: 7 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 diff --git a/filtermail/.github/workflows/ci.yml b/filtermail/.github/workflows/ci.yml new file mode 100644 index 00000000..605333bc --- /dev/null +++ b/filtermail/.github/workflows/ci.yml @@ -0,0 +1,107 @@ +name: CI + +permissions: {} + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + fmt: + name: Check Formatting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master + with: + toolchain: nightly + components: rustfmt + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 + - uses: taiki-e/install-action@2ca9b94c269419b7b0c711c09d0b21c4e1d51145 # v2 + with: + tool: taplo-cli + - run: taplo format --check && cargo fmt --check + + cargo-machete: + name: Check Unused Dependencies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: bnjbvr/cargo-machete@ac30a525c0a8d163a92d727b3ff079ee3f6ecb08 # v0.9.2 + + lint-clippy: + name: Check Clippy + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + toolchain: ["stable", "beta"] + continue-on-error: ${{ matrix.toolchain == 'beta' }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master + with: + toolchain: ${{ matrix.toolchain }} + components: clippy + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 + - run: cargo clippy + env: + RUSTUP_TOOLCHAIN: ${{ matrix.toolchain }} + + rust-test: + name: Run rust tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master + with: + toolchain: stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 + - run: cargo test + + relay-test: + name: Test the relay with cmlxc and custom filtermail + uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@main + with: + cmlxc_commands: | + sudo apt-get update && sudo apt-get install -y musl-tools + rustup target add x86_64-unknown-linux-musl + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc + cd repo && RUSTFLAGS="-Ctarget-feature=+crt-static -Clink-self-contained=yes" cargo build --release --target x86_64-unknown-linux-musl + cmlxc init + cmlxc -v deploy-cmdeploy --filtermail repo/target/x86_64-unknown-linux-musl/release/filtermail cm0 + cmlxc -v deploy-cmdeploy --filtermail repo/target/x86_64-unknown-linux-musl/release/filtermail --type ipv4 cm1 + cmlxc -v deploy-madmail mad0 + + # single-relay tests + cmlxc -v test-cmdeploy cm0 + cmlxc -v test-cmdeploy cm1 + cmlxc -v test-mini cm0 + cmlxc -v test-mini cm1 + + # cross-relay tests (Standard <-> IP-only <-> Madmail) + cmlxc -v test-cmdeploy cm0 cm1 + cmlxc -v test-cmdeploy cm1 cm0 + cmlxc -v test-cmdeploy cm0 mad0 + cmlxc -v test-cmdeploy cm1 mad0 + cmlxc -v test-mini cm0 mad0 + cmlxc -v test-mini mad0 cm0 + cmlxc -v test-mini cm1 mad0 + cmlxc -v test-mini mad0 cm1 diff --git a/filtermail/.github/workflows/release.yml b/filtermail/.github/workflows/release.yml new file mode 100644 index 00000000..af1825df --- /dev/null +++ b/filtermail/.github/workflows/release.yml @@ -0,0 +1,67 @@ +on: + release: + types: [published] + +jobs: + build: + name: Build binaries. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master + with: + toolchain: stable + targets: aarch64-unknown-linux-musl,x86_64-unknown-linux-musl + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + - uses: taiki-e/install-action@2ca9b94c269419b7b0c711c09d0b21c4e1d51145 # v2 + with: + tool: cargo-zigbuild + - name: Build + run: | + RUSTFLAGS="-Ctarget-feature=+crt-static -Clink-self-contained=yes" \ + cargo zigbuild \ + --release \ + --target x86_64-unknown-linux-musl \ + --target aarch64-unknown-linux-musl + - name: Upload x86_64 binary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: filtermail-x86_64 + path: target/x86_64-unknown-linux-musl/release/filtermail + if-no-files-found: error + - name: Upload aarch64 binary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: filtermail-aarch64 + path: target/aarch64-unknown-linux-musl/release/filtermail + if-no-files-found: error + + publish: + name: Upload binaries to the release + needs: ["build"] + runs-on: ubuntu-latest + steps: + - name: Download aarch64 binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: filtermail-x86_64 + path: filtermail-x86_64 + - name: Download x86_64 binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: filtermail-aarch64 + path: filtermail-aarch64 + - name: Upload binaries to the GitHub release + if: github.event_name == 'release' + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + REF_NAME: ${{ github.ref_name }} + run: | + mkdir dist + mv filtermail-x86_64/filtermail dist/filtermail-x86_64 + mv filtermail-aarch64/filtermail dist/filtermail-aarch64 + gh release upload "$REF_NAME" \ + --repo ${{ github.repository }} \ + dist/* diff --git a/filtermail/.gitignore b/filtermail/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/filtermail/.gitignore @@ -0,0 +1 @@ +/target diff --git a/filtermail/CHANGELOG.md b/filtermail/CHANGELOG.md new file mode 100644 index 00000000..5904fd05 --- /dev/null +++ b/filtermail/CHANGELOG.md @@ -0,0 +1,291 @@ +## 0.7.4 - 2026-07-01 + +### Features + +- *(logs)* Log incoming mailer-daemon message sources (#191) + +### Miscellaneous Tasks + +- Update filtermail.mtail (#190) + +### Testing + +- Place #[tokio::test] after rstest case macros (#192) +## 0.7.3 - 2026-06-27 + +### Features + +- *(transport)* Worker eviction (#185) +## 0.7.2 - 2026-06-26 + +### Documentation + +- *(readme)* Disable colors in mermaid diagrams (#180) +- *(readme)* Update Transport mode doc + +### Features + +- *(transport)* Destination worker pool + +### Refactor + +- Implement Display for AddressDomain + +### Testing + +- Test filtermail-transport +## 0.7.1 - 2026-06-09 + +### Bug Fixes + +- Switch ratelimiter to MonotonicClock (#167) +- *(smtp-server)* Correct error when EOF while reading DATA (#168) +- *(resolver)* Disable negative caching (#170) +- Ignore CNAME records when resolving TXT records (#177) + +### Features + +- Switch to aws-lc-rs cryptography provider (#178) + +### Miscellaneous Tasks + +- Add filtermail.mtail so filtermail failures can be monitored (#169) +## 0.7.0 - 2026-05-26 + +### Bug Fixes + +- Do not crash if accepting new connection fails + +### Documentation + +- *(readme)* Remove docs for options removed in da9a116 (#162) + +### Features + +- [**breaking**] Remove passthrough options that allowed unencrypted mail to pass +## 0.6.6 - 2026-05-12 + +### Bug Fixes + +- Return HTTP 200 because madmail expects it, and make sure https is immediately retried when SMTP fails (#153) + +### Features + +- Improved SMTP error responses (#147) + +### Miscellaneous Tasks + +- Remove mac and windows from matrix tests (#154) +- Run cmlxc tests in all classic/classic-ipv4/madmail combinations +## 0.6.5 - 2026-05-12 + +### Bug Fixes + +- *(smtp-client)* Handle 421 on reused connection (#145) +- Advertise 8BITMIME to prevent conversion after DKIM signing (#149) +- Validate mail data (#150) + +### Miscellaneous Tasks + +- Build and deploy relays with filtermail binary and run interop tests against madmail (#131) + +### Performance + +- *(smtp-client)* Use pipelining if server advertises support (#146) +## 0.6.4 - 2026-05-01 + +### Bug Fixes + +- Implement missing "implicit MX" rule + +### Features + +- Https transport channel (#122) + +### Refactor + +- *(transport)* Explicitly handle RFC7505 null MX +## 0.6.3 - 2026-04-20 + +### Features + +- *(smtp-server)* Log malformed SMTP commands (#118) + +### Performance + +- *(smtp-client)* Cache connections (#117) +## 0.6.2 - 2026-04-16 + +### Documentation + +- *(readme)* Reformat README.md (#115) + +### Features + +- Log disabled recipients (#113) +- *(transport)* Remote delivery over SMTP (#104) + +### Miscellaneous Tasks + +- *(dependabot)* Update configuration (#114) +## 0.6.1 - 2026-04-01 + +### Documentation + +- *(readme)* Fix typo (#99) + +### Features + +- Configurable hosts for listen and reinject (#84) +- *(resolver)* Enable DNSSEC (#94) +- Add experimental option to disable mailboxes (#108) + +### Refactor + +- Derive Default for Envelope (#100) +## 0.6.0 - 2026-03-13 + +### Documentation + +- *(readme)* Fix license link +- *(readme)* Clarify licensing of the binaries +- *(readme)* Improve README.md (#88) + +### Features + +- [**breaking**] Remove IP verification for domain-literals (#90) + +### Miscellaneous Tasks + +- *(ci)* Fix binary publish job (#79) + +### Refactor + +- Use enum for mode cli arg (#83) +## 0.5.2 - 2026-02-27 + +### Bug Fixes + +- *(logs)* Log correct address for outbound messages (#70) + +### Features + +- Check incoming email return address (#72) + +### Miscellaneous Tasks + +- Bump cargo-dist (#73) +- *(ci)* Build and upload binaries in CI (#76) +- *(ci)* Add missing Zig dependency (#77) + +### Refactor + +- Check if email is encrypted before verifying DKIM (#71) +## 0.5.1 - 2026-02-24 + +### Bug Fixes + +- *(dkim)* Accept TXT records with no `v=` tag (#62) +- *(smtp)* Properly handle bounce messages (#63) +- *(dkim)* Accept TXT records with escaped quotes (#61) +- *(logs)* Log `From` address instead of envelope `MAIL FROM`. (#66) +## 0.5.0 - 2026-02-20 + +### Bug Fixes + +- *(dkim)* Make simple header canonicalization work properly (#53) + +### Features + +- Save rejected messages to `/tmp` (#55) + +### Refactor + +- Do not copy the mail in memory for DKIM verification (#54) +## 0.4.1 - 2026-02-17 + +### Miscellaneous Tasks + +- *(tests)* Add a way to disable DKIM for tests (#50) +## 0.4.0 - 2026-02-17 + +### Features + +- [**breaking**] DKIM verifier (#35) +- Support addresses using domain literals (#42) +## 0.3.0 - 2026-02-14 + +### Features + +- Support legacy, pre-OpenPGP packet format (#44) + +### Miscellaneous Tasks + +- *(dist)* Switch to musl targets (#31) + +### Refactor + +- Remove unnecessary Arc (#36) +- Use a custom, minimal SMTP client instead of lettre (#33) +## 0.2.0 - 2026-01-28 + +### Features + +- Configurable rate limiter max burst size (#28) + +### Performance + +- Disable Nagle's algorithm and do own buffering on server connections + +### Refactor + +- Remove Mutex around rate limiter +## 0.1.2 - 2026-01-22 + +### Bug Fixes + +- Set logs required by grafana to INFO (#21) +- Make inbound/outbound log messages consistent (#23) + +### Performance + +- Use governor for rate limiting (#20) +## 0.1.1 - 2026-01-21 + +### Bug Fixes + +- Improve address extraction from SMTP commands (#14) +- Correct a typo in SMTP answer (#11) +- *(config)* Set default values for internal SMTP ports and max message size (#12) + +### Features + +- Improve logging (#13) + +### Miscellaneous Tasks + +- *(dist)* Configure cargo-dist (#10) +- Configure git-cliff + +### Refactor + +- Get rid of indexing and slicing in check_armored_payload() (#15) +- Apply more lints (#17) +## 0.1.0 - 2026-01-19 + +### Documentation + +- *(readme)* Add README.md +- *(license)* Add LICENSE + +### Features + +- Initial implementation + +### Miscellaneous Tasks + +- Init repository +- *(dependabot)* Setup dependabot +- *(ci)* Setup CI +- *(dependabot)* Add github-actions to dependabot +- *(cargo)* Add metadata diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock new file mode 100644 index 00000000..dc1e5463 --- /dev/null +++ b/filtermail/Cargo.lock @@ -0,0 +1,3109 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "charset" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" +dependencies = [ + "base64", + "encoding_rs", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filtermail" +version = "0.7.4" +dependencies = [ + "async-trait", + "base64", + "env_logger", + "governor", + "hickory-resolver", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "insta", + "log", + "lru", + "mailparse", + "memchr", + "parking_lot", + "retainer", + "rstest", + "serde", + "serial_test", + "serini", + "testresult", + "thiserror", + "tokio", + "tokio-io-timeout", + "tokio-rustls", + "viadkim", + "webpki-roots", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "aws-lc-rs", + "bitflags", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna 1.1.0", + "ipnet", + "jni", + "lru-cache", + "parking_lot", + "rand 0.10.1", + "rustls-pki-types", + "thiserror", + "time", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "aws-lc-rs", + "bitflags", + "data-encoding", + "idna 1.1.0", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "rustls-pki-types", + "thiserror", + "time", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.1", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +dependencies = [ + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.0", +] + +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "mailparse" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f" +dependencies = [ + "charset", + "data-encoding", + "quoted_printable", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prefix-trie" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23370be78b7e5bcbb0cab4a02047eb040279a693c78daad04c2c5f1c24a83503" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "retainer" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b071fe646a2ab077f74656a4602c16528829c1fafa81946c5e88eaeccf08d5b" +dependencies = [ + "async-io", + "async-lock", + "futures-lite", + "log", + "rand 0.9.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serini" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3948771e3c915493235146627cddf2d65de15396033da39d918637a02fda6fb" +dependencies = [ + "serde", + "thiserror", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "testresult" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614b328ff036a4ef882c61570f72918f7e9c5bee1da33f8e7f91e01daee7e56c" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna 1.1.0", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "viadkim" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fbe3d7dd3cf3431b0151b92997000d346dba564c229c5933ef1a9cc16f13949" +dependencies = [ + "base64ct", + "digest", + "ed25519-dalek", + "idna 0.4.0", + "pkcs8", + "rsa", + "sha2", + "tokio", + "tracing", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml new file mode 100644 index 00000000..2318673d --- /dev/null +++ b/filtermail/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "filtermail" +version = "0.7.4" +edition = "2024" +license = "MIT" +readme = "README.md" +authors = ["Chatmail Developers"] +description = "A mail filtering service used by chatmail relay" +repository = "https://github.com/chatmail/filtermail" + +[dependencies] +async-trait = "0.1.89" +base64 = "0.22.1" +serde = { version = "1.0.228", features = ["derive"] } +serini = "0.2.2" +tokio = { version = "1.52.3", features = ["full"] } +thiserror = "2.0.18" +mailparse = "0.16.1" +memchr = "2.8.1" +log = "0.4.30" +env_logger = "0.11.10" +governor = { version = "0.10.4", default-features = false, features = [ + "std", + "dashmap", +] } +viadkim = { version = "0.2.0" } +hickory-resolver = { version = "0.26.1", features = ["dnssec-aws-lc-rs"] } +lru = "0.18.0" +parking_lot = "0.12.5" +tokio-rustls = { version = "0.26.4", default-features = false, features = [ + "aws-lc-rs", + "logging", + "tls12", +] } +webpki-roots = "1.0.7" +tokio-io-timeout = "1.2.1" +retainer = "0.4.0" +hyper = { version = "1.8.1", features = ["server"] } +hyper-util = { version = "0.1.20", features = [ + "tokio", + "server", + "server-auto", + "client-legacy", +] } +http-body-util = "0.1.3" +hyper-rustls = { version = "0.27.9", default-features = false, features = [ + "aws-lc-rs", + "http1", + "http2", + "logging", + "tls12", +] } + +[dev-dependencies] +rstest = "0.26.1" +testresult = "0.4.1" +insta = "1.48.0" +serial_test = "3.5.0" + +[profile.release] +lto = "thin" diff --git a/filtermail/LICENSE b/filtermail/LICENSE new file mode 100644 index 00000000..765a23dd --- /dev/null +++ b/filtermail/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2026, chatmail and delta chat teams + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/filtermail/LICENSE-GPL b/filtermail/LICENSE-GPL new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/filtermail/LICENSE-GPL @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/filtermail/README.md b/filtermail/README.md new file mode 100644 index 00000000..dff94713 --- /dev/null +++ b/filtermail/README.md @@ -0,0 +1,203 @@ +# filtermail + +A postfix smtpd proxy filter used by [chatmail relay](https://github.com/chatmail/relay). + +Filtermail is a fast, minimal and secure Rust-based SMTP before-queue filter. +By acting as a protocol-aware proxy for incoming and outgoing messages, +it enforces mandatory end-to-end encryption, +performs DKIM verification, +and handles per-sender rate limiting. + +## Usage + +```plain +filtermail (incoming|outgoing|transport) +``` +where `` is a path to `chatmail.ini` configuration file. + +Filtermail can be used in `incoming`, `outgoing` or `transport` mode. + +### Incoming mode + +```mermaid +flowchart LR + + subgraph chatmail relay + subgraph postfix + smtpd1[smtpd] + smtpd2[smtpd] --> queue[...] + end + nginx[nginx] + smtpd1 -.SMTP :10081.-> filtermail[filtermail-incoming] + nginx -.HTTP :10082.-> filtermail + filtermail --SMTP :10026--> smtpd2 + end + mta[Sender's relay] -.SMTP :25.-> smtpd1 + mta -.HTTPS /mxdeliv.-> nginx +``` + +Filtermail in incoming mode acts as a proxy filter +for messages received from remote MTAs and performs following steps: + +1. Rejects messages if `DATA` exceeds configured message size limit. +2. Rejects messages that do not meet at least one of the following criteria: + - PGP encrypted, + - securejoin message, + - mailer-daemon message, + - all recipients allow cleartext + (`enforceE2EEincoming` is not present in their mailbox directory). +3. If `MAIL FROM` doesn't match `From` header, + the address is removed from `MAIL FROM` on reinjection + (prevents bounces to possibly spoofed `MAIL FROM`). +4. Checks message origin, + depending on address type: + - **domain** - performs a strict DKIM verification and domain alignment check + (domain of address from `From` header must exactly match the DKIM signature domain), + rejecting messages that fail. + - **domain-literal (IP address)** - currently no-op. +5. In case of a DKIM failure, + the message is saved to `/tmp/filtermail-rejected/dkim-verify` directory for later inspection. + +In contrast to outgoing mode, incoming mode starts with not only SMTP but also HTTP listener. +Built-in HTTP server doesn't handle TLS, and should be placed behind a TLS-terminating reverse proxy. + +### Outgoing mode + +```mermaid +flowchart LR + + subgraph chatmail relay + subgraph postfix + smtpd1[smtpd] + smtpd2[smtpd] --> queue[...] + end + smtpd1 --SMTP :10080--> filtermail[filtermail-outgoing] + filtermail --SMTP :10025--> smtpd2 + open-dkim[OpenDKIM] <--milter--> smtpd2 + end + client[Client] --SMTP :587--> smtpd1 +``` + +Filtermail in outgoing mode acts as a proxy filter +for messages received from clients and performs following steps: + +1. Rejects messages at `MAIL FROM` stage if the address exceeded rate limit. +2. Rejects messages if `DATA` exceeds configured message size limit. +3. Rejects messages which `From` header address does not match one in `MAIL FROM`. +4. Rejects messages that do not meet at least one of the following criteria: + - PGP encrypted, + - securejoin message, + - self-sent Autocrypt Setup Message, + +### Transport mode + +```mermaid +flowchart LR + + subgraph chatmail relay + subgraph postfix + qmgr[...] --> lmtp[default-transport lmtp] + end + + lmtp --LMTP :10083--> filtermail[filtermail-transport] + end + filtermail -.SMTP :25.-> mta[Recipient's relay] + filtermail -.HTTPS /mxdeliv.-> mta +``` + +Filtermail in transport mode is used for final delivery to remote MTAs. +As opposed to incoming/outgoing, it accepts connections from postfix over LMTP instead of SMTP, +to allow returning per-recipient status back to postfix. +Received message is split per-domain and sent to recipients' MX servers over HTTP and SMTP, +enforcing TLS. + +As opposed to postfix, IPv4 and IPv6 connections are tried in parallel and first successful connection is used. +HTTP delivery channel is preferred, +and SMTP is used only if HTTP delivery fails. + +Filtermail spawns a separate worker for each destination +(distinguished by [domain][RFC5322_3_4_1] part of the recipient's [addr-spec][RFC5322_3_4_1], +NOT the actual MX server). +Only messages to the same destination are guaranteed to be sent in-order +(if not deferred and sent over the same LMTP connection); +messages to different destinations are NOT synchronized. + +[RFC5322_3_4_1]: https://datatracker.ietf.org/doc/html/rfc5322#section-3.4.1 + +## Configuration + +### chatmail.ini + +Filtermail shares the same configuration file as chatmail relay, +but implements a custom parser that only requires a small subset of configuration options: + +- `filtermail_smtp_port` - port to listen on in outgoing mode, + defaults to `10080`. +- `filtermail_smtp_port_incoming` - SMTP port to listen on in incoming mode, + defaults to `10081`. +- `filtermail_http_port_incoming` - HTTP port to listen on in incoming mode, + defaults to `10082`. +- `filtermail_lmtp_port_transport` - port to listen on in transport mode, + defaults to `10083`. +- `postfix_reinject_port` - port to reinject messages to postfix in outgoing mode, + defaults to `10025`. +- `postfix_reinject_port_incoming` - port to reinject messages to postfix in incoming mode, + defaults to `10026`. +- `max_message_size` - maximum allowed message size in bytes, + defaults to `31457280` (30 MiB). +- `max_user_send_per_minute` - email sending rate per user and minute, + defaults to `60`. +- `max_user_send_burst_size` - per-user max burst size for sending rate limiting (GCRA bucket capacity), + defaults to `10`. +- `mail_domain` - domain name used in email addresses. +- `mailboxes_dir` - path to mailboxes directory, + defaults to `/home/vmail/mail/`. + +The following options are Filtermail-specific, +they are not read by other chatmail relay components +and usually do not need to be set at all: + +- `filtermail_host` - IP address to listen on, + defaults to `127.0.0.1`. +- `postfix_host` - hostname or IP address where postfix is set up, + a host is resolved only on Filtermail startup, + useful in case MTA runs somewhere outside of localhost, + defaults to `127.0.0.1`. + +### Environment variables + +Additional options that can be set using environment variables: + +- `RUST_LOG` - set log level, + defaults to `info`. +- `FILTERMAIL_SKIP_DKIM` - completely skip DKIM verification; + only for testing purposes and not recommended for production use, + defaults to `0`. + +## Usage outside of chatmail relay + +**Filtermail development is focused on supporting it as a systemd service used by chatmail relay.** +Although unsupported, it may still work outside of this context or even without postfix, +with few considerations: + +- Filtermail expects to receive messages from trusted clients, + and thus should not listen on ports exposed directly to the internet. +- Issues outside of chatmail relay context are not necessarily considered bugs; + PRs fixing them are not guaranteed to be accepted. + (Trivial changes may still be considered, + please open an issue to discuss any such changes before working on them). + + +## Releases + +Filtermail is distributed as a statically linked linux binary, +available for `x86_64` and `aarch64` architectures. + +Binaries are available on the [releases page](https://github.com/chatmail/filtermail/releases). + +## License + +Code licensed under [MIT](LICENSE). + +Binary releases of `filtermail` link with `viadkim` +and are thus subject to the [GPL-3.0-or-later](LICENSE-GPL). diff --git a/filtermail/cliff.toml b/filtermail/cliff.toml new file mode 100644 index 00000000..f8786675 --- /dev/null +++ b/filtermail/cliff.toml @@ -0,0 +1,94 @@ +# git-cliff ~ configuration file +# https://git-cliff.org/docs/configuration + + +[changelog] +# A Tera template to be rendered for each release in the changelog. +# See https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %} +""" +# Remove leading and trailing whitespaces from the changelog's body. +trim = true +# Render body even when there are no releases to process. +render_always = true +# An array of regex based postprocessors to modify the changelog. +postprocessors = [ + # Replace the placeholder with a URL. + #{ pattern = '', replace = "https://github.com/orhun/git-cliff" }, +] +# render body even when there are no releases to process +# render_always = true +# output file path +# output = "test.md" + +[git] +# Parse commits according to the conventional commits specification. +# See https://www.conventionalcommits.org +conventional_commits = true +# Exclude commits that do not match the conventional commits specification. +filter_unconventional = true +# Require all commits to be conventional. +# Takes precedence over filter_unconventional. +require_conventional = false +# Split commits on newlines, treating each line as an individual commit. +split_commits = false +# An array of regex based parsers to modify commit messages prior to further processing. +commit_preprocessors = [ + # Replace issue numbers with link templates to be updated in `changelog.postprocessors`. + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit message using https://github.com/crate-ci/typos. + # If the spelling is incorrect, it will be fixed automatically. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# Prevent commits that are breaking from being excluded by commit parsers. +protect_breaking_commits = false +# An array of regex based parsers for extracting data from the commit message. +# Assigns commits to groups. +# Optionally sets the commit's scope and can decide to exclude commits from further processing. +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^docs", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore|^ci", group = "Miscellaneous Tasks" }, + { body = ".*security", group = "Security" }, + { message = "^revert", group = "Revert" }, + { message = ".*", group = "Other" }, +] +# Exclude commits that are not matched by any commit parser. +filter_commits = false +# Fail on a commit that is not matched by any commit parser. +fail_on_unmatched_commit = false +# An array of link parsers for extracting external references, and turning them into URLs, using regex. +link_parsers = [] +# Include only the tags that belong to the current branch. +use_branch_tags = false +# Order releases topologically instead of chronologically. +topo_order = false +# Order commits topologically instead of chronologically. +topo_order_commits = true +# Order of commits in each group/release within the changelog. +# Allowed values: newest, oldest +sort_commits = "oldest" +# Process submodules commits +recurse_submodules = false diff --git a/filtermail/contrib/filtermail.mtail b/filtermail/contrib/filtermail.mtail new file mode 100644 index 00000000..a37f6218 --- /dev/null +++ b/filtermail/contrib/filtermail.mtail @@ -0,0 +1,101 @@ +# filtermail.mtail: filtermail process event counters + +# +# Counters +# + +# Connection errors in the transport path +# Error::Io / Error::ConnectionFailed / Error::Tls: the reason string filtermail strips +counter filtermail_transport_error_total by reason + +# Unexpected transport errors: DNS failure, HTTP error, config error. +counter filtermail_transport_unexpected_total + +# Silent drops: inbound +# "unencrypted" overlaps with rejected_unencrypted_mail_count in delivered_mail.mtail. +counter filtermail_inbound_drop_total by reason + +# Silent drops: outbound +# "unencrypted" overlaps with rejected_unencrypted_mail_count in delivered_mail.mtail; +# "sender_disabled" and "all_recipients_disabled" are new. +counter filtermail_outbound_drop_total by reason + +# Reinject failures +counter filtermail_reinject_error_total by direction + +# SMTP-level connection errors (client dropped mid-session) +counter filtermail_smtp_error_total by reason + +# +# filtermail::transport +# + +/filtermail\[\d+\]: \[WARN\s+filtermail::transport::worker\] Connection error relaying to mail server \S+: / { + /timed out/ { + filtermail_transport_error_total["connection_timeout"]++ + } otherwise { + /Failed to connect to any of the following addresses/ { + filtermail_transport_error_total["connection_refused"]++ + } otherwise { + /Connection refused/ { + filtermail_transport_error_total["connection_refused"]++ + } otherwise { + /invalid certificate/ { + filtermail_transport_error_total["tls_cert_not_verified"]++ + } otherwise { + /peer sent fatal alert|handshake/ { + filtermail_transport_error_total["tls_handshake"]++ + } otherwise { + /close_notify|connection closed/ { + filtermail_transport_error_total["lost_connection"]++ + } otherwise { + filtermail_transport_error_total["other"]++ + }}}}}}} + +/filtermail\[\d+\]: \[WARN\s+filtermail::transport::worker\] Unexpected error while delivering/ { + filtermail_transport_unexpected_total++ +} + +# +# filtermail::smtp_server +# + +/filtermail(?:-incoming)?\[\d+\]: \[WARN\s+filtermail::smtp_server\] Unexpected EoF while receiving DATA/ { + filtermail_smtp_error_total["unexpected_eof"]++ +} + +/filtermail(?:-incoming)?\[\d+\]: \[WARN\s+filtermail::smtp_server\] Malformed DATA line without CRLF/ { + filtermail_smtp_error_total["malformed_data_line"]++ +} + +# +# filtermail::outbound +# + +/filtermail\[\d+\]: \[WARN\s+filtermail::outbound\] Rejected unencrypted mail/ { + filtermail_outbound_drop_total["unencrypted"]++ +} + +/filtermail\[\d+\]: \[WARN\s+filtermail::outbound\] Dropping mail; Sender .* is disabled/ { + filtermail_outbound_drop_total["sender_disabled"]++ +} + +/filtermail\[\d+\]: \[WARN\s+filtermail::outbound\] Dropping mail; All recipients disabled/ { + filtermail_outbound_drop_total["all_recipients_disabled"]++ +} + +/filtermail\[\d+\]: \[WARN\s+filtermail::outbound\] Failed to re.inject mail/ { + filtermail_reinject_error_total["outbound"]++ +} + +# +# filtermail::inbound +# + +/filtermail(?:-incoming)?\[\d+\]: \[WARN\s+filtermail::inbound\] Rejected unencrypted mail/ { + filtermail_inbound_drop_total["unencrypted"]++ +} + +/filtermail(?:-incoming)?\[\d+\]: \[WARN\s+filtermail::inbound\] Failed to re.inject mail/ { + filtermail_reinject_error_total["inbound"]++ +} diff --git a/filtermail/src/config.rs b/filtermail/src/config.rs new file mode 100644 index 00000000..86c7f9d9 --- /dev/null +++ b/filtermail/src/config.rs @@ -0,0 +1,143 @@ +//! Configuration file handling for filtermail. + +use serde::Deserialize; +use std::net::IpAddr; +use std::num::NonZeroU32; +use std::path::{Path, PathBuf}; + +/// Chatmail configuration subset used by filtermail. +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + #[serde(default = "Config::default_filtermail_host")] + pub filtermail_host: IpAddr, + #[serde(default = "Config::default_filtermail_smtp_port")] + pub filtermail_smtp_port: u16, + #[serde(default = "Config::default_filtermail_smtp_port_incoming")] + pub filtermail_smtp_port_incoming: u16, + #[serde(default = "Config::default_filtermail_http_port_incoming")] + pub filtermail_http_port_incoming: u16, + #[serde(default = "Config::default_filtermail_lmtp_port_transport")] + pub filtermail_lmtp_port_transport: u16, + #[serde(default = "Config::default_postfix_host")] + pub postfix_host: String, + #[serde(default = "Config::default_postfix_reinject_port")] + pub postfix_reinject_port: u16, + #[serde(default = "Config::default_postfix_reinject_port_incoming")] + pub postfix_reinject_port_incoming: u16, + #[serde(default = "Config::default_max_message_size")] + pub max_message_size: usize, + #[serde(default = "Config::default_max_user_send_per_minute")] + pub max_user_send_per_minute: NonZeroU32, + #[serde(default = "Config::default_max_user_send_burst_size")] + pub max_user_send_burst_size: NonZeroU32, + pub mail_domain: String, + mailboxes_dir: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct ConfigWrapper { + // The whole actual config is under `params` section. + pub params: Config, +} + +impl Config { + /// Load configuration from a file. + pub fn from_file(path: impl AsRef) -> Result { + let content = std::fs::read_to_string(path)?; + let wrapped_config: ConfigWrapper = serini::from_str(&content)?; + Ok(wrapped_config.params) + } + + /// Get the mailboxes directory, defaulting to `/home/vmail/mail/` if not set. + fn mailboxes_dir(&self) -> PathBuf { + match &self.mailboxes_dir { + Some(dir) => dir.clone(), + None => PathBuf::from(format!("/home/vmail/mail/{}", self.mail_domain)), + } + } + + /// Check if a specific flag file exists for the given address. + /// + /// Returns `default` if the address is invalid. + fn check_flag(&self, addr: &str, flag: &str, default: bool) -> bool { + if addr.is_empty() || !addr.contains('@') || addr.contains('/') { + return default; + } + + let mut path = self.mailboxes_dir(); + path.push(addr); + path.push(flag); + + path.exists() + } + + /// Check if not encrypted mail is allowed for the given address. + pub fn is_cleartext_ok(&self, addr: &str) -> bool { + !self.check_flag(addr, "enforceE2EEincoming", true) + } + + /// Check if the given address is disabled. + pub fn is_disabled(&self, addr: &str) -> bool { + self.check_flag(addr, "DISABLED", false) + } + + // Following are needed since serde does not support default literals. + + const fn default_filtermail_host() -> IpAddr { + IpAddr::V4(std::net::Ipv4Addr::LOCALHOST) + } + const fn default_filtermail_smtp_port() -> u16 { + 10080 + } + const fn default_filtermail_smtp_port_incoming() -> u16 { + 10081 + } + const fn default_filtermail_http_port_incoming() -> u16 { + 10082 + } + const fn default_filtermail_lmtp_port_transport() -> u16 { + 10083 + } + fn default_postfix_host() -> String { + "127.0.0.1".to_owned() + } + const fn default_postfix_reinject_port() -> u16 { + 10025 + } + const fn default_postfix_reinject_port_incoming() -> u16 { + 10026 + } + const fn default_max_message_size() -> usize { + 31457280 + } + const fn default_max_user_send_per_minute() -> NonZeroU32 { + NonZeroU32::new(60).expect("60 != 0") + } + const fn default_max_user_send_burst_size() -> NonZeroU32 { + NonZeroU32::new(10).expect("10 != 0") + } +} + +#[cfg(test)] +impl Default for Config { + /// Creates a default configuration with example.org domain. + /// + /// Used for tests. + fn default() -> Self { + Self { + filtermail_host: Self::default_filtermail_host(), + filtermail_smtp_port: Self::default_filtermail_smtp_port(), + filtermail_smtp_port_incoming: Self::default_filtermail_smtp_port_incoming(), + filtermail_http_port_incoming: Self::default_filtermail_http_port_incoming(), + filtermail_lmtp_port_transport: Self::default_filtermail_lmtp_port_transport(), + postfix_host: Self::default_postfix_host(), + postfix_reinject_port: Self::default_postfix_reinject_port(), + postfix_reinject_port_incoming: Self::default_postfix_reinject_port_incoming(), + max_message_size: Self::default_max_message_size(), + max_user_send_per_minute: Self::default_max_user_send_per_minute(), + max_user_send_burst_size: Self::default_max_user_send_burst_size(), + mail_domain: "example.org".to_string(), + mailboxes_dir: None, + } + } +} diff --git a/filtermail/src/dkim_verifier.rs b/filtermail/src/dkim_verifier.rs new file mode 100644 index 00000000..8872ed35 --- /dev/null +++ b/filtermail/src/dkim_verifier.rs @@ -0,0 +1,298 @@ +use hickory_resolver::{TokioResolver, proto::rr::Name}; +use lru::LruCache; +use std::io; +use std::num::NonZeroUsize; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; +use viadkim::VerificationStatus; +use viadkim::message_hash::BodyHasherStance; +use viadkim::verifier::LookupTxt; + +// ~500kB when fully saturated (~420B per RDATA + selector). +// "top 1000 relays" is much more than enough, the limit is mostly to prevent DoS attacks. +const LRU_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).expect("1000 != 0"); + +/// Normalizes a TXT record RDATA by removing irrelevant characters. +/// +/// Some DKIM key records use e.g. LF + WSP line breaks. +/// This is technically not correct, and `viadkim` fails to parse such records, +/// but in practice this is accepted by many implementations. +/// +/// Additionally, removes escaped quotes, as such records as: +/// `"...UL9" "\" \"7vGm..."` proved to still be accepted by e.g. dkimpy or OpenDKIM. +fn normalize_rdata(txt_data: &str) -> String { + txt_data.replace([' ', '\t', '\n', '\r', '"'], "") +} + +/// DNS resolver for DKIM TXT records, that caches RDATA in memory. +#[derive(Clone)] +struct CachedResolver { + dns_resolver: Arc, + // Note: Arc is required despite we are holding the whole handler in an Arc, + // because viadkim will internally clone the resolver (LookupTxt + Clone + 'static) + // to parallelize lookups in case of multiple signatures... + cache: Arc>>>>, +} + +impl CachedResolver { + /// Creates a new [`CachedResolver`]. + pub fn new(dns_resolver: Arc) -> Self { + let cache = Arc::new(parking_lot::Mutex::new(LruCache::new(LRU_CACHE_CAPACITY))); + + Self { + dns_resolver, + cache, + } + } + + /// Invalidates the cached RDATA for a given selector and domain. + /// + /// Fails silently. + fn invalidate_cache(&self, selector: &str, domain: &str) { + let selector_domain_str = format!("{}._domainkey.{}.", selector, domain); + if let Ok(selector_domain) = Name::from_ascii(&selector_domain_str) { + let mut cache = self.cache.lock(); + cache.pop(&selector_domain); + log::debug!("Cache invalidated for {}", selector_domain_str); + } else { + log::warn!( + "Failed to parse selector domain for cache invalidation: {}", + selector_domain_str + ); + } + } +} + +impl LookupTxt for CachedResolver { + type Answer = Box>>>; + type Query<'a> = Pin> + Send + 'a>>; + + fn lookup_txt(&self, domain: &str) -> Self::Query<'_> { + let name = Name::from_ascii(domain); + Box::pin(async move { + let name = name.map_err(|_| io::ErrorKind::InvalidInput)?; + + { + let mut cache = self.cache.lock(); + if let Some(txts) = cache.get(&name) { + let txts: Self::Answer = Box::new(txts.clone().into_iter().map(Ok)); + log::debug!("Using cached TXT records for {}", name); + return Ok(txts); + } + } + + log::debug!("Trying to resolve TXT records for {}", name); + let txts: Vec> = { + let lookup = self + .dns_resolver + .txt_lookup(name.clone()) + .await + .map_err(io::Error::other)?; + + // viadkim would filter out non-DKIM TXT records, + // but we filter it here anyway so that we know which one should be cached. + lookup + .answers() + .iter() + .filter(|record| { + // Select only TXT records. + // When resolving TXT query, CNAMEs are also returned as answers. + // We want to filter out CNAMEs first. + matches!(record.data, hickory_resolver::proto::rr::RData::TXT(_)) + }) + // We don't check all records, as this can be a DoS attack vector. + // In theory, selector domains should only have a single TXT record. + // In practice, we check at most 3, just in case of weird configuration. + .take(3) + .map(|txt| { + let rdata = txt.data.to_string(); + log::trace!("TXT (raw rdata): {:?}", rdata); + let normalized = normalize_rdata(&rdata); + log::trace!("TXT (concatenated and normalized): {:?}", normalized); + normalized.into_bytes() + }) + .collect() + }; + + { + let mut cache = self.cache.lock(); + cache.put(name, txts.clone()); + } + + let txts: Self::Answer = Box::new(txts.into_iter().map(Ok)); + Ok(txts) + }) + } +} + +/// Dummy resolver that always returns the same TXT record, for testing purposes. +#[derive(Clone)] +struct MockResolver(String); + +impl LookupTxt for MockResolver { + type Answer = Box>>>; + type Query<'a> = Pin> + Send + 'a>>; + + fn lookup_txt(&self, _domain: &str) -> Self::Query<'_> { + Box::pin(async move { + let txts: Self::Answer = + Box::new(std::iter::once(Ok(normalize_rdata(&self.0).into_bytes()))); + Ok(txts) + }) + } +} + +/// Either a real resolver or a mock. +#[derive(Clone)] +enum Resolver { + /// A [`CachedResolver`] + Real(CachedResolver), + /// A [`MockResolver`] + Mock(MockResolver), +} + +impl LookupTxt for Resolver { + type Answer = Box>>>; + type Query<'a> = Pin> + Send + 'a>>; + + fn lookup_txt(&self, domain: &str) -> Self::Query<'_> { + match self { + Resolver::Real(resolver) => resolver.lookup_txt(domain), + Resolver::Mock(resolver) => resolver.lookup_txt(domain), + } + } +} + +impl From for Resolver { + fn from(value: CachedResolver) -> Self { + Resolver::Real(value) + } +} + +impl From for Resolver { + fn from(value: MockResolver) -> Self { + Resolver::Mock(value) + } +} + +/// DKIM verifier using a pre-configured [`viadkim`] verifier, a [`CachedResolver`] for DNS lookups, +/// and strict domain name alignment check. +pub struct DkimVerifier { + resolver: Resolver, + config: viadkim::Config, +} + +impl DkimVerifier { + /// Creates a new [`DkimVerifier`] with the provided resolver. + pub fn new(dns_resolver: Arc) -> Self { + let resolver = CachedResolver::new(dns_resolver).into(); + let config = viadkim::Config { + lookup_timeout: Duration::from_secs(60), + ..Default::default() + }; + Self { resolver, config } + } + + /// Creates a new [`DkimVerifier`] with a mock resolver that always returns the provided TXT record. + #[cfg(test)] + fn mock(txt: String) -> Self { + let resolver = MockResolver(txt).into(); + let config = viadkim::Config { + lookup_timeout: Duration::from_secs(60), + ..Default::default() + }; + Self { resolver, config } + } + + /// Verifies the DKIM signature of a raw email message and its alignment with the provided + /// domain. + pub async fn verify(&self, raw_mail: &[u8], from_domain: &str) -> Result<(), String> { + let mail_data = str::from_utf8(raw_mail).or(Err("554 Non-UTF-8 message"))?; + let (header, body) = mail_data + .split_once("\r\n\r\n") + .ok_or("554 Malformed data")?; + + let header = header.parse().map_err(|_| "554 Malformed header")?; + + let Some(mut verifier) = + viadkim::Verifier::verify_header(&self.resolver, &header, &self.config).await + else { + return Err("554 5.7.1 No DKIM signature found".to_string()); + }; + + 'hasher: for chunk in body.as_bytes().chunks(8192) { + if verifier.process_body_chunk(chunk) == BodyHasherStance::Done { + break 'hasher; + } + } + + for res in verifier.finish() { + log::debug!("Signature {}: {:?}", res.index, res.status); + + let Some(signature) = &res.signature else { + log::debug!("Signature {}: No signature found, skipping", res.index); + continue; + }; + + if matches!(res.status, VerificationStatus::Failure(_)) { + log::debug!("Signature {}: Verification failed, skipping", res.index); + // We only invalidate cache on actual validation error, and not alignment error. + // TODO: ideally we should retry without cache and swap cached value only on success. + if let Resolver::Real(resolver) = &self.resolver { + resolver + .invalidate_cache(signature.selector.as_ref(), signature.domain.as_ref()); + } + continue; + } + + if !signature + .domain + .to_string() + .eq_ignore_ascii_case(from_domain) + { + log::debug!( + "Signature {}: Domain different than in From header, skipping", + res.index + ); + continue; + } + + return Ok(()); + } + + Err("554 5.7.1 No valid DKIM signature found".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::simple_simple_canonicalization( + r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5krC4Xi5Wkr6eMlla38LCFmV645E3FLAgsRl2YJ0SrZ4N2Vw1/yH0mefvtk7HYE7ytV7RQl/er2CkSsaHLJSYLmPCBw5CO6PSsBSXuh6DBqdylh/1t9vVQ9p38fTwn9gU1QvplcpRQL9eepRra1k24VMIaVy2ZZcu3LI9zkPsR7o7TyNaeMhsL8ouWInWc1NSid+p0SgliQuwHIejZhlTPE60JLbJE0OR9I4wmq3377H6z/QrO8XeabCgtmTuzE/hTRyIyNS40jql/99pjlhIcjM2U+P2B0FjwYt7BwLHsgANr74ctlnKY+SdH25rNwVpPmkotaULG5SJCByKBkfCwIDAQAB;s=email;t=s"#, + include_bytes!("../test_data/dkim-abjadiyah.eml"), + "abjadiyah.xyz" + )] + #[case::txt_escaped_quotes( + r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1giTh8KDkEchWhrAB6hGnb+V87kTezkt5I3SP7BGNg8wpv0yAuj/SUmnsttYmcEU+zmNAPqxePmCNvmjLYi/c3YyWEBwHcLyZE9OlS9W4enPdsoCuEN3DayzN4JCV3MsXMedCORvLFXmIARDXDLJUSJeqCeQoudXa9GmF1CrCmx70YyTtV0xOIxEzo7z0DkUL9" "7vGmNJCv6EMpi9wccMKKu8NSmOv+DBw1MLIJqChSZMCs8CYZ5i0KT/+Lijtn6B7wyOcAuQsVL+zr7DWYrFdrePe0wGuivfJ3SvUEfUo1SIykl0nvm0iLGhjNmNa1e/tUw4ULXhQ12Qw685+sq7wIDAQAB;s=email;t=s"#, + include_bytes!("../test_data/dkim-privitty.eml"), + "chat.privittytech.com" + )] + #[tokio::test] + async fn test_dkim_verifier(#[case] txt: &str, #[case] message: &[u8], #[case] domain: &str) { + let verifier = DkimVerifier::mock(txt.to_string()); + verifier.verify(message, domain).await.unwrap(); + } + + #[rstest] + #[case::escaped_quotes( + r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1giTh8KDkEchWhrAB6hGnb+V87kTezkt5I3SP7BGNg8wpv0yAuj/SUmnsttYmcEU+zmNAPqxePmCNvmjLYi/c3YyWEBwHcLyZE9OlS9W4enPdsoCuEN3DayzN4JCV3MsXMedCORvLFXmIARDXDLJUSJeqCeQoudXa9GmF1CrCmx70YyTtV0xOIxEzo7z0DkUL9" "7vGmNJCv6EMpi9wccMKKu8NSmOv+DBw1MLIJqChSZMCs8CYZ5i0KT/+Lijtn6B7wyOcAuQsVL+zr7DWYrFdrePe0wGuivfJ3SvUEfUo1SIykl0nvm0iLGhjNmNa1e/tUw4ULXhQ12Qw685+sq7wIDAQAB;s=email;t=s"#, + r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1giTh8KDkEchWhrAB6hGnb+V87kTezkt5I3SP7BGNg8wpv0yAuj/SUmnsttYmcEU+zmNAPqxePmCNvmjLYi/c3YyWEBwHcLyZE9OlS9W4enPdsoCuEN3DayzN4JCV3MsXMedCORvLFXmIARDXDLJUSJeqCeQoudXa9GmF1CrCmx70YyTtV0xOIxEzo7z0DkUL97vGmNJCv6EMpi9wccMKKu8NSmOv+DBw1MLIJqChSZMCs8CYZ5i0KT/+Lijtn6B7wyOcAuQsVL+zr7DWYrFdrePe0wGuivfJ3SvUEfUo1SIykl0nvm0iLGhjNmNa1e/tUw4ULXhQ12Qw685+sq7wIDAQAB;s=email;t=s"# + )] + fn test_normalize_rdata(#[case] input: &str, #[case] expected: &str) { + assert_eq!(normalize_rdata(input), expected); + } +} diff --git a/filtermail/src/error.rs b/filtermail/src/error.rs new file mode 100644 index 00000000..873f7134 --- /dev/null +++ b/filtermail/src/error.rs @@ -0,0 +1,83 @@ +//! Error types. + +use tokio_rustls::rustls; + +/// Error type for filtermail. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("Chatmail config is invalid: {0}")] + Config(#[from] serini::Error), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Resolve(#[from] hickory_resolver::net::NetError), + #[error("OpenPGP packet header is truncated - can't validate!")] + TruncatedHeader, + #[error("Unable to send email, Error during {context}, host {host} said: {raw_smtp_answer}")] + MailSend { + context: String, + raw_smtp_answer: String, + host: String, + }, + #[error("Invalid email address: {0}")] + InvalidEmailAddress(String), + #[error("Failed to connect to any of the following addresses: {0:?}")] + ConnectionFailed(Vec), + #[error(transparent)] + Tls(#[from] rustls::Error), + #[error(transparent)] + InvalidDnsName(#[from] rustls::pki_types::InvalidDnsNameError), + #[error(transparent)] + Hyper(#[from] hyper::Error), + #[error(transparent)] + HyperHttp(#[from] hyper::http::Error), + #[error(transparent)] + HyperClient(#[from] hyper_util::client::legacy::Error), +} + +impl Error { + /// Formats [`Error`] as an SMTP response. + pub fn smtp_response(&self) -> String { + macro_rules! format_smtp { + ($code:expr) => { + format!("{} {}", $code, self.to_string()) + }; + } + + match self { + // Errors returned by server we connect to are forwarded. + // We add "(forwarded from ...)" to distinguish these from our local errors. + Error::MailSend { + raw_smtp_answer, + host, + .. + } => format!("{raw_smtp_answer} (forwarded from {host})"), + + // Permanent errors + Error::TruncatedHeader => format_smtp!("554"), + Error::InvalidEmailAddress(_) => format_smtp!("553"), + Error::InvalidDnsName(_) => format_smtp!("501"), + + // Transient errors + Error::ConnectionFailed(_) => format_smtp!("450"), + // We don't want to leak chatmail.ini config and other local error details. + Error::Config(_) => "451 Filtermail misconfigured; contact admin".to_string(), + Error::Io(_) => "451 I/O error".to_string(), + Error::Tls(_) => "451 TLS error".to_string(), + Error::Resolve(_) => "451 Resolver error".to_string(), + Error::Hyper(_) | Error::HyperHttp(_) | Error::HyperClient(_) => { + "451 HTTP error".to_string() + } + } + } + + /// Same as [`smtp_response`](Self::smtp_response) but formats the same response + /// for each recipient, as expected by LMTP. + pub fn lmtp_response(&self, recipient_count: usize) -> String { + let response = self.smtp_response(); + std::iter::repeat_n(response, recipient_count) + .collect::>() + .join("\r\n") + } +} diff --git a/filtermail/src/http_server.rs b/filtermail/src/http_server.rs new file mode 100644 index 00000000..d9115398 --- /dev/null +++ b/filtermail/src/http_server.rs @@ -0,0 +1,169 @@ +use crate::smtp_server::{SmtpHandler, Transaction}; +use http_body_util::combinators::BoxBody; +use http_body_util::{BodyExt, Full}; +use hyper::body::{Bytes, Incoming}; +use hyper::service::Service; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use std::convert::Infallible; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; +use tokio::net::{TcpListener, TcpStream}; + +/// Runs the HTTP server on the specified address with the given handler and maximum message size. +pub async fn run_http_server( + addr: &impl tokio::net::ToSocketAddrs, + handler: Arc, + max_size: usize, +) -> Result<(), crate::error::Error> +where + H: SmtpHandler + 'static, +{ + let listener = TcpListener::bind(addr).await?; + loop { + match listener.accept().await { + Ok((socket, _peer_addr)) => { + // Disable Nagle's algorithm. + socket.set_nodelay(true)?; + + let handler = handler.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(socket, handler, max_size).await { + log::error!("Error handling connection: {e}"); + } + }); + } + Err(e) => { + log::error!("Error accepting connection: {e}"); + + // Sleep to avoid busy looping in case we ran into file descriptor limit. + tokio::time::sleep(Duration::from_secs(10)).await; + } + } + } +} + +/// Handles a single HTTP connection. +async fn handle_connection( + socket: TcpStream, + handler: Arc, + max_size: usize, +) -> Result<(), String> +where + H: SmtpHandler + 'static, +{ + let service = MxDelivService::new(handler, max_size); + + hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new()) + .serve_connection(TokioIo::new(socket), service) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +struct MxDelivService { + handler: Arc, + max_size: usize, +} + +impl MxDelivService { + /// Creates a new [`MxDelivService`]. + fn new(handler: Arc, max_size: usize) -> Self { + Self { handler, max_size } + } +} + +impl Service> for MxDelivService { + type Response = Response>; + type Error = crate::error::Error; + type Future = Pin> + Send>>; + + fn call(&self, req: Request) -> Self::Future { + let handler = self.handler.clone(); + let max_size = self.max_size; + + let fut = async move { + if req.method() != hyper::Method::POST { + return Ok(Response::builder().status(405).body( + // This is client's implementation error if it happens, + // so we don't care about sending a proper smtp response. + Full::new(Bytes::from("Method Not Allowed")).boxed(), + )?); + } + + let mut transaction = Transaction::default(); + + let mail_from = req + .headers() + .get(crate::transport::HEADER_MAIL_FROM) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + if let Err(e) = handler.handle_mail_from(&mail_from) { + return Ok(Response::builder() + .status(400) + .body(Full::new(Bytes::from(e)).boxed())?); + } + transaction.envelope.mail_from = mail_from; + + let rcpt_to: Vec = req + .headers() + .get_all(crate::transport::HEADER_RCPT_TO) + .iter() + .filter_map(|v| v.to_str().ok()) + .map(ToString::to_string) + .collect(); + + for r in &rcpt_to { + if let Err(e) = handler.handle_rcpt_to(r, &mut transaction) { + return Ok(Response::builder() + .status(400) + .body(Full::new(Bytes::from(e)).boxed())?); + } + } + transaction.envelope.rcpt_to = rcpt_to; + + if let Err(e) = handler.handle_data_start(&transaction) { + return Ok(Response::builder() + .status(400) + .body(Full::new(Bytes::from(e)).boxed())?); + } + + let body_limited = http_body_util::Limited::new(req.into_body(), max_size); + let body_bytes = match body_limited.collect().await { + Ok(body) => body.to_bytes(), + Err(_) => { + return Ok(Response::builder().status(413).body( + Full::new(Bytes::from("552 Message exceeds maximum size")).boxed(), + )?); + } + }; + + transaction.envelope.data = body_bytes.to_vec(); + + log::debug!("(HTTP) MAIL FROM:<{}>", transaction.envelope.mail_from); + for rcpt in &transaction.envelope.rcpt_to { + log::debug!("(HTTP) RCPT TO:<{}>", rcpt); + } + + log::trace!( + "(HTTP) DATA:\n{:?}", + String::from_utf8_lossy(&transaction.envelope.data) + ); + + match handler.handle_data_dot(&mut transaction).await { + Ok(response) => Ok(Response::builder() + .status(200) + .body(Full::new(Bytes::from(response)).boxed())?), + Err(e) => Ok(Response::builder() + .status(400) + .body(Full::new(Bytes::from(e)).boxed())?), + } + }; + + Box::pin(fut) + } +} diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs new file mode 100644 index 00000000..c0e24b52 --- /dev/null +++ b/filtermail/src/inbound.rs @@ -0,0 +1,222 @@ +//! Module for handling incoming SMTP messages. + +use crate::config::Config; +use crate::dkim_verifier::DkimVerifier; +use crate::message::{check_encrypted, is_securejoin}; +use crate::smtp_client::SmtpConnectionPool; +use crate::smtp_responses::ENCRYPTION_NEEDED_523; +pub use crate::smtp_server::Envelope; +use crate::smtp_server::{SmtpHandler, Transaction}; +use crate::tcp::{TcpConnect, TcpStreamTrait}; +use crate::utils::{AddressDomain, build_resolver, extract_address, log_eml}; +use async_trait::async_trait; +use hickory_resolver::TokioResolver; +use mailparse::{MailHeaderMap, parse_mail}; +use std::str::FromStr; +use std::sync::Arc; + +/// Handler for incoming SMTP messages. +pub struct IncomingBeforeQueueHandler { + config: Config, + dns_resolver: Arc, + dkim_verifier: DkimVerifier, + skip_dkim: bool, + smtp_connection_pool: Arc>, +} + +impl IncomingBeforeQueueHandler +where + S: TcpStreamTrait + TcpConnect, + S::ConnectionContext: Default, +{ + pub fn new(config: Config, skip_dkim: bool) -> Result { + let dns_resolver = Arc::new(build_resolver()?); + Ok(Self { + config, + dns_resolver: dns_resolver.clone(), + dkim_verifier: DkimVerifier::new(dns_resolver), + skip_dkim, + smtp_connection_pool: SmtpConnectionPool::new(Default::default()), + }) + } + + /// Verify the origin of the email by performing a DKIM verification on a regular domain. + /// + /// Currently a no-op for valid domain-literals. + async fn verify_origin(&self, envelope: &Envelope, from_addr: &str) -> Result<(), String> { + let from_domain = AddressDomain::from_str(from_addr).map_err(|e| e.smtp_response())?; + + match from_domain { + AddressDomain::Literal(_) => { + // Subject to change: we currently don't perform any additional authentication + // for domain-literals and rely purely on encryption. + } + AddressDomain::Name(domain) => { + if !self.skip_dkim + && let Err(e) = self.dkim_verifier.verify(&envelope.data, &domain).await + { + let eml_path = log_eml("dkim-verify", &envelope.data) + .await + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|e| { + log::error!("Failed to save rejected message to file: {e}"); + "ERR".to_string() + }); + log::info!("Rejected message stored at: {eml_path}"); + return Err(e); + } + } + } + + Ok(()) + } +} + +#[async_trait] +impl SmtpHandler for IncomingBeforeQueueHandler +where + S: TcpStreamTrait + TcpConnect, + S::ConnectionContext: Default, +{ + type State = (); + + async fn check_data(&self, transaction: &mut Transaction) -> Result<(), String> { + let message = match parse_mail(&transaction.envelope.data) { + Ok(m) => m, + Err(e) => return Err(format!("500 Failed to parse message: {}", e)), + }; + + let from_header = message + .headers + .get_first_value("From") + .unwrap_or_default() + .trim() + .to_string(); + + let Some(from_addr) = extract_address(&from_header) else { + return Err(format!("500 Invalid FROM header: {from_header}")); + }; + + log::debug!("Processing DATA message from {from_addr}"); + + if !transaction + .envelope + .mail_from + .eq_ignore_ascii_case(&from_addr) + { + // If the MAIL FROM doesn't match the From header, we do not reject the mail, + // as this can be caused by e.g. SRS forwarding. + // Instead, we reset the envelope address, so it is reinjected as + // `MAIL FROM:<>` to prevent sending a bounce message. + // + transaction.envelope.mail_from = String::new(); + } + + transaction.envelope.rcpt_to = transaction + .envelope + .rcpt_to + .iter() + .filter(|s| { + let disabled = self.config.is_disabled(s); + if disabled { + log::warn!("Disabled recipient: {s}; removing from RCPT TO"); + } + !disabled + }) + .cloned() + .collect(); + + let mail_encrypted = check_encrypted(&message, false); + log::debug!("mail_encrypted: {mail_encrypted}"); + log::debug!("is_securejoin: {}", is_securejoin(&message)); + + // Allow encrypted or securejoin messages + if mail_encrypted || is_securejoin(&message) { + log::info!("Incoming: Filtering encrypted mail."); + return self.verify_origin(&transaction.envelope, &from_addr).await; + } + + // Allow cleartext mailer-daemon messages + if let Some(auto_submitted) = message.headers.get_first_value("Auto-Submitted") + && !auto_submitted.is_empty() + && from_addr.to_lowercase().starts_with("mailer-daemon@") + && message.ctype.mimetype == "multipart/report" + { + log::info!("Incoming: Filtering mailer-daemon message from <{from_addr}>"); + return self.verify_origin(&transaction.envelope, &from_addr).await; + } else { + log::info!("Incoming: Filtering unencrypted mail."); + } + + for recipient in &transaction.envelope.rcpt_to { + if !self.config.is_cleartext_ok(recipient) { + log::warn!("Rejected unencrypted mail from: {from_addr}"); + return Err(ENCRYPTION_NEEDED_523.to_string()); + } + } + + self.verify_origin(&transaction.envelope, &from_addr).await + } + + async fn reinject_mail(&self, transaction: &Transaction) -> Result<(), String> { + log::debug!("Re-injecting the mail that passed checks"); + let hostname = format!("[{}]", self.config.filtermail_host); + let client_config = crate::smtp_client::ClientConfig { + client_hostname: &hostname, + tls_config: None, + lmtp: false, + }; + crate::smtp_client::send( + &self.config.postfix_host, + self.config.postfix_reinject_port_incoming, + &transaction.envelope, + client_config, + self.dns_resolver.clone(), + self.smtp_connection_pool.clone(), + ) + .await + .map_err(|e| { + log::warn!("Failed to re-inject mail: {}", e); + e.smtp_response() + })?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::{fixture, rstest}; + use testresult::TestResult; + use tokio::net::TcpStream; + + #[fixture] + fn config() -> Config { + Config::default() + } + + /// Test that domain-literals are not rejected by origin check. + #[rstest] + #[case::ipv4(include_bytes!("../test_data/encrypted-ipv4.eml"), "one@[192.0.2.0]")] + // Waiting for a release of mailparse with the fix https://github.com/staktrace/mailparse/pull/138 + // for the issue https://github.com/staktrace/mailparse/issues/137 to be released. + //#[case::ipv6(include_bytes!("../test_data/encrypted-ipv6.eml"), "one@[IPv6:2001:db8::1]")] + #[tokio::test] + async fn test_domain_literals_allowed( + #[case] eml: &[u8], + #[case] address: &str, + config: Config, + ) -> TestResult { + let handler = IncomingBeforeQueueHandler::::new(config, false)?; + let mut transaction = Transaction { + envelope: Envelope { + mail_from: address.to_string(), + data: eml.to_vec(), + rcpt_to: vec!["does.not.matter@example.org".to_string()], + }, + ..Default::default() + }; + Ok(handler.check_data(&mut transaction).await?) + } +} diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs new file mode 100644 index 00000000..dcf5b2e6 --- /dev/null +++ b/filtermail/src/main.rs @@ -0,0 +1,199 @@ +#![doc = include_str!("../README.md")] +#![forbid(unsafe_code)] +#![warn( + unused, + clippy::correctness, + missing_debug_implementations, + missing_docs, + clippy::all, + clippy::wildcard_imports, + clippy::needless_borrow, + clippy::cast_lossless, + clippy::unused_async, + clippy::explicit_iter_loop, + clippy::explicit_into_iter_loop, + clippy::cloned_instead_of_copied +)] +#![cfg_attr(not(test), forbid(clippy::indexing_slicing))] +#![cfg_attr(not(test), forbid(clippy::string_slice))] +#![allow( + clippy::match_bool, + clippy::mixed_read_write_in_expression, + clippy::bool_assert_comparison, + clippy::manual_split_once, + clippy::format_push_string, + clippy::bool_to_int_with_if +)] +mod config; +mod dkim_verifier; +pub(crate) mod error; +mod http_server; +pub(crate) mod inbound; +pub(crate) mod message; +pub(crate) mod openpgp; +pub(crate) mod outbound; +pub(crate) mod smtp_client; +mod smtp_responses; +pub(crate) mod smtp_server; +mod tcp; +mod tls; +mod transport; +pub(crate) mod utils; + +use crate::http_server::run_http_server; +use crate::transport::TransportHandler; +use config::Config; +use env_logger::Env; +use inbound::IncomingBeforeQueueHandler; +use outbound::OutgoingBeforeQueueHandler; +use smtp_server::run_smtp_server; +use std::env; +use std::process; +use std::str::FromStr; +use std::sync::Arc; +use tokio::net::TcpStream; + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum Mode { + Outgoing, + Incoming, + Transport, +} + +impl FromStr for Mode { + type Err = &'static str; + + fn from_str(mode: &str) -> Result { + match mode { + "outgoing" => Ok(Mode::Outgoing), + "incoming" => Ok(Mode::Incoming), + "transport" => Ok(Mode::Transport), + _ => Err("Error: mode must be 'incoming', 'outgoing' or 'transport'"), + } + } +} + +#[tokio::main] +async fn main() -> Result<(), error::Error> { + // default to info level + let env = Env::new().filter_or("RUST_LOG", "info"); + env_logger::Builder::from_env(env) + // disable timestamps - automatically added by systemd + .format_timestamp(None) + .init(); + + tokio_rustls::rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("Failed to set up rustls crypto provider."); + + let args: Vec = env::args().collect(); + if args.len() != 3 { + eprintln!( + "Usage: {} ", + args.first().unwrap_or(&"filtermail".to_string()) + ); + eprintln!(" mode: incoming, outgoing or transport"); + process::exit(1); + } + + let Some(config_path) = args.get(1) else { + unreachable!("args length checked above") + }; + let Some(mode) = args.get(2) else { + unreachable!("args length checked above") + }; + + let mode = match Mode::from_str(mode) { + Ok(mode) => mode, + Err(e) => { + eprintln!("{e}"); + process::exit(1); + } + }; + + let config = match Config::from_file(config_path) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to read config: {}", e); + process::exit(1); + } + }; + + match mode { + Mode::Outgoing => { + let addr = (config.filtermail_host, config.filtermail_smtp_port); + let handler = Arc::new(OutgoingBeforeQueueHandler::::new( + config.clone(), + )?); + let max_size = config.max_message_size; + log::debug!("Outgoing SMTP server listening on {}:{}", addr.0, addr.1); + + if let Err(e) = run_smtp_server(&addr, handler, max_size).await { + eprintln!("Server error: {}", e); + process::exit(1); + } + } + Mode::Incoming => { + // Skip DKIM verification (used for tests). + let skip_dkim = env::var("FILTERMAIL_SKIP_DKIM") + .map(|val| val == "1" || val.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + if skip_dkim { + log::warn!("DKIM verification DISABLED! This should not be used in production."); + } + + let handler = Arc::new(IncomingBeforeQueueHandler::::new( + config.clone(), + skip_dkim, + )?); + let max_size = config.max_message_size; + + let mut server_set = tokio::task::JoinSet::new(); + + let addr_smtp = (config.filtermail_host, config.filtermail_smtp_port_incoming); + let handler_smtp = handler.clone(); + server_set + .spawn(async move { run_smtp_server(&addr_smtp, handler_smtp, max_size).await }); + log::debug!( + "Incoming SMTP server listening on {}:{}", + addr_smtp.0, + addr_smtp.1 + ); + + let addr_http = (config.filtermail_host, config.filtermail_http_port_incoming); + let handler_http = handler.clone(); + + server_set + .spawn(async move { run_http_server(&addr_http, handler_http, max_size).await }); + log::debug!( + "Incoming HTTP server listening on {}:{}", + addr_http.0, + addr_http.1 + ); + + while let Some(result) = server_set.join_next().await { + if let Err(e) = result { + eprintln!("Server error: {}", e); + process::exit(1); + } + } + } + Mode::Transport => { + let addr = ( + config.filtermail_host, + config.filtermail_lmtp_port_transport, + ); + let handler = Arc::new(TransportHandler::::new(config.clone())?); + let max_size = config.max_message_size; + log::debug!("Transport SMTP server listening on {}:{}", addr.0, addr.1); + + if let Err(e) = run_smtp_server(&addr, handler, max_size).await { + eprintln!("Server error: {}", e); + process::exit(1); + } + } + }; + + Ok(()) +} diff --git a/filtermail/src/message.rs b/filtermail/src/message.rs new file mode 100644 index 00000000..da7c7a99 --- /dev/null +++ b/filtermail/src/message.rs @@ -0,0 +1,174 @@ +//! Message-related checks. + +use crate::openpgp::check_armored_payload; +use mailparse::MailHeaderMap; + +/// Check if message is a secure-join message (vc-request or vg-request) +pub fn is_securejoin(mail: &mailparse::ParsedMail) -> bool { + // Check for secure-join header + let secure_join = mail.headers.get_first_value("Secure-Join"); + if let Some(ref val) = secure_join { + if val != "vc-request" && val != "vg-request" { + return false; + } + } else { + return false; + } + + // Must be multipart + if mail.subparts.is_empty() { + return false; + } + + // Must have only one part + if mail.subparts.len() != 1 { + return false; + } + + let Some(part) = &mail.subparts.first() else { + return false; + }; + + // Part must not be multipart + if !part.subparts.is_empty() { + return false; + } + + // Part must be text/plain + if part.ctype.mimetype != "text/plain" { + return false; + } + + // Check payload content + let payload = match part.get_body() { + Ok(p) => p.trim().to_lowercase(), + Err(_) => return false, + }; + + payload == "secure-join: vc-request" || payload == "secure-join: vg-request" +} + +/// Check that the message is an OpenPGP-encrypted message +/// +/// MIME structure must correspond to RFC3156 +pub fn check_encrypted(mail: &mailparse::ParsedMail, outgoing: bool) -> bool { + if mail.subparts.is_empty() { + log::debug!("check_encrypted: not multipart"); + return false; + } + if !mail + .ctype + .mimetype + .eq_ignore_ascii_case("multipart/encrypted") + { + log::debug!("check_encrypted: not multipart/encrypted"); + return false; + } + for (part_idx, part) in mail.subparts.iter().enumerate() { + // Each part must not be multipart + if !part.subparts.is_empty() { + log::debug!("check_encrypted: part of multipart/encrypted is itself multipart"); + return false; + } + + if part_idx == 0 { + // First part must be application/pgp-encrypted + if !part + .ctype + .mimetype + .eq_ignore_ascii_case("application/pgp-encrypted") + { + log::debug!( + "check_encrypted: first part not application/pgp-encrypted, got: {}", + part.ctype.mimetype + ); + return false; + } + + // Payload must be "Version: 1" + let payload = match part.get_body() { + Ok(p) => p, + Err(_) => { + log::debug!("check_encrypted: failed to get body of first part"); + return false; + } + }; + if payload.trim() != "Version: 1" { + log::debug!( + "check_encrypted: first part payload not 'Version: 1', got {}", + payload.trim() + ); + return false; + } + } else if part_idx == 1 { + // Second part must be application/octet-stream + if part.ctype.mimetype != "application/octet-stream" { + log::debug!( + "check_encrypted: second part not application/octet-stream, got: {}", + part.ctype.mimetype + ); + return false; + } + + // Check the armored payload + let payload = match part.get_body() { + Ok(p) => p, + Err(_) => { + log::debug!("check_encrypted: failed to get body of second part"); + return false; + } + }; + if !check_armored_payload(&payload, outgoing) { + log::debug!("check_encrypted: armored payload check failed"); + return false; + } + } else { + log::debug!("check_encrypted: more than two parts found"); + return false; + } + } + + true +} + +#[cfg(test)] +mod tests { + use super::*; + use mailparse::parse_mail; + use rstest::*; + use testresult::TestResult; + + #[rstest] + #[case::asm("test_data/asm.eml", false)] + #[case::encrypted("test_data/encrypted.eml", false)] + #[case::fake_encrypted("test_data/fake-encrypted.eml", false)] + #[case::literal("test_data/literal.eml", false)] + #[case::mailer_daemon("test_data/mailer-daemon.eml", false)] + #[case::mdn("test_data/mdn.eml", false)] + #[case::plain("test_data/plain.eml", false)] + #[case::securejoin_vc("test_data/securejoin-vc.eml", true)] + #[case::securejoin_vc_fake("test_data/securejoin-vc-fake.eml", false)] + fn test_is_securejoin(#[case] file: &str, #[case] expected: bool) -> TestResult { + let raw_email = std::fs::read_to_string(file)?; + let parsed = parse_mail(raw_email.as_bytes())?; + assert_eq!(is_securejoin(&parsed), expected); + Ok(()) + } + + #[rstest] + #[case::asm("test_data/asm.eml", false)] + #[case::encrypted("test_data/encrypted.eml", true)] + #[case::fake_encrypted("test_data/fake-encrypted.eml", false)] + #[case::literal("test_data/literal.eml", false)] + #[case::mailer_daemon("test_data/mailer-daemon.eml", false)] + #[case::mdn("test_data/mdn.eml", false)] + #[case::plain("test_data/plain.eml", false)] + #[case::securejoin_vc("test_data/securejoin-vc.eml", false)] + #[case::securejoin_vc_fake("test_data/securejoin-vc-fake.eml", false)] + fn test_check_encrypted(#[case] file: &str, #[case] expected: bool) -> TestResult { + let raw_email = std::fs::read_to_string(file)?; + let parsed = parse_mail(raw_email.as_bytes())?; + assert_eq!(check_encrypted(&parsed, false), expected); + Ok(()) + } +} diff --git a/filtermail/src/openpgp.rs b/filtermail/src/openpgp.rs new file mode 100644 index 00000000..c66bf40b --- /dev/null +++ b/filtermail/src/openpgp.rs @@ -0,0 +1,350 @@ +//! OpenPGP payload checker. + +use crate::error; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + +/// Tries to get the byte `$idx` of the array slice `$payload`. +/// +/// Returns [`error::Error::TruncatedHeader`] in the outer function, if `$idx` is out of range. +macro_rules! get_byte { + ($payload:expr, $idx:expr) => { + *$payload.get($idx).ok_or(error::Error::TruncatedHeader)? + }; +} + +/// Checks the OpenPGP payload. +/// +/// OpenPGP payload must consist only of `PKESK` and `SKESK` packets terminated by a single `SEIPD` packet. +/// +/// Returns `Ok(true)` if OpenPGP payload is correct, `Ok(false)` otherwise. +/// +/// # Errors +/// +/// Returns an [`error::Error::TruncatedHeader`] if the OpenPGP packet header is truncated. +fn check_openpgp_payload(payload: &[u8]) -> Result { + // From RFC9580; we have to support legacy format too, for compatibility with GnuPG. + // + // +---------------+ + // Encoded Packet Type ID: |7 6 5 4 3 2 1 0| + // +---------------+ + // OpenPGP format: + // Bit 7 -- always one + // Bit 6 -- always one + // Bits 5 to 0 -- Packet Type ID + // + // Legacy format: + // Bit 7 -- always one + // Bit 6 -- always zero + // Bits 5 to 2 -- Packet Type ID + // Bits 1 to 0 -- length-type + + let mut i: usize = 0; + while i < payload.len() { + let bits_7_6 = get_byte!(payload, i) & 0xC0; + // bit 6 is 0 for legacy (GnuPG) + let legacy = if bits_7_6 == 0xC0 { + false + } else if bits_7_6 == 0x80 { + true + } else { + log::debug!( + "check_openpgp_payload: i={i} bits 7 and 6 doesn't indicate OpenPGP or legacy format" + ); + return Ok(false); + }; + + let packet_type_id = if legacy { + // bits 5 to 2 + (get_byte!(payload, i) & 0x3C) >> 2 + } else { + // bits 5 to 0 + get_byte!(payload, i) & 0x3F + }; + + if legacy { + // Body length calculation - Legacy, RFC9580 4.2.2 + + let length_type = get_byte!(payload, i) & 0x03; + i += 1; + + let body_len: usize; + match length_type { + 0 => { + // One-octet length. + body_len = get_byte!(payload, i) as usize; + i += 1; + } + 1 => { + // Two-octet length. + body_len = ((get_byte!(payload, i) as usize) << 8) + + (get_byte!(payload, i + 1) as usize); + i += 2; + } + 2 => { + // Four-octet length. + body_len = ((get_byte!(payload, i) as usize) << 24) + + ((get_byte!(payload, i + 1) as usize) << 16) + + ((get_byte!(payload, i + 2) as usize) << 8) + + (get_byte!(payload, i + 3) as usize); + i += 4; + } + // 3 - indeterminate length, not supported + // RFC9580 4.2.2: + // "An implementation MUST NOT generate a Legacy format packet with indeterminate length." + // We hope they don't. + _ => { + log::debug!( + "check_openpgp_payload: i={i} Indeterminate length (3) length-type in legacy packet format" + ); + return Ok(false); + } + }; + + i += body_len; + } else { + // Body length calculation - OpenPGP, RFC9580 4.2.1 + + i += 1; + + while get_byte!(payload, i) >= 224 && get_byte!(payload, i) < 255 { + // Partial body length. + let partial_length = 1usize << (get_byte!(payload, i) & 0x1F); + i += 1 + partial_length; + } + + let body_len: usize; + if get_byte!(payload, i) < 192 { + // One-octet length. + body_len = get_byte!(payload, i) as usize; + i += 1; + } else if get_byte!(payload, i) < 224 { + // Two-octet length. + body_len = (((get_byte!(payload, i) as usize) - 192) << 8) + + (get_byte!(payload, i + 1) as usize) + + 192; + i += 2; + } else if get_byte!(payload, i) == 255 { + // Five-octet length. + body_len = ((get_byte!(payload, i + 1) as usize) << 24) + | ((get_byte!(payload, i + 2) as usize) << 16) + | ((get_byte!(payload, i + 3) as usize) << 8) + | (get_byte!(payload, i + 4) as usize); + i += 5; + } else { + // Impossible, partial body length was processed above. + log::debug!("check_openpgp_payload: i={i} Invalid body length"); + return Ok(false); + } + + i += body_len; + } + + if i == payload.len() { + // Last packet should be + // Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD) + // + // This is the only place where this function may return `True`. + log::debug!("check_openpgp_payload: i={i} packat_type_id={packet_type_id}"); + return Ok(packet_type_id == 18); + } else if ![1, 3].contains(&packet_type_id) { + // All packets except the last one must be either + // Public-Key Encrypted Session Key Packet (PKESK) + // or + // Symmetric-Key Encrypted Session Key Packet (SKESK) + log::debug!("check_openpgp_payload: i={i} packet_type_id={packet_type_id}"); + return Ok(false); + } + } + + Ok(false) +} + +/// Check the armored PGP message for invalid content. +/// +/// Returns `true` if the `payload` is a valid PGP message, +/// `outgoing` informs whether the message is outgoing or incoming +pub fn check_armored_payload(payload: &str, outgoing: bool) -> bool { + const PREFIX: &str = "-----BEGIN PGP MESSAGE-----\r\n"; + let Some(payload) = payload.strip_prefix(PREFIX) else { + log::debug!("check_armored_payload: Did not find PGP MESSAGE prefix"); + return false; + }; + + let payload = payload.trim_end_matches("\r\n"); + const SUFFIX: &str = "-----END PGP MESSAGE-----"; + let Some(mut payload) = payload.strip_suffix(SUFFIX) else { + log::debug!("check_armored_payload: Did not find PGP MESSAGE suffix"); + return false; + }; + + const VERSION_COMMENT: &str = "Version: "; + if payload.starts_with(VERSION_COMMENT) { + // Disallow comments in outgoing messages + if outgoing { + log::debug!("check_armored_payload: Comment found in outgoing message"); + return false; + } + // Remove comments from incoming messages + if let Some((_, right)) = payload.split_once("\r\n") { + payload = right; + } + } + + let mut payload = payload.trim_start_matches("\r\n"); + + // Remove CRC24. + if let Some((left, _)) = payload.rsplit_once('=') { + payload = left; + } + + let payload = payload.replace(['\r', '\n'], ""); + let payload = match BASE64_STANDARD.decode(payload.as_bytes()) { + Ok(v) => v, + Err(_) => { + log::debug!("check_armored_payload: Base64 decoding failed"); + return false; + } + }; + + check_openpgp_payload(&payload).unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::*; + + #[rstest] + #[case::valid(r#"-----BEGIN PGP MESSAGE----- + +wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg +O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae +8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI +JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no +lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz +ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM +YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA +kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI ++lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg +RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo +tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7 +rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp +H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI +fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9 +61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN +XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3 +w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb +NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs +baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW +A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8 +uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI +E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn +lkOWnEbCD+XTnbDd +=agR5 +-----END PGP MESSAGE-----"#, (true, true))] + // GnuPG uses a legacy packet format that was obsoleted back in 1998 (RFC2440); + // despite RFC9580 stating "The Legacy packet format SHOULD NOT be used to generate new data". + #[case::gnupg(r#"-----BEGIN PGP MESSAGE----- + +hF4DFYw3E47UG/ASAQdA3JnkJLJJALitYG/jzzEuF0p+hEBsgLTicaEJkJerInkw +SftuCMBjxv4D6rTqNkBn7IQh6gmlUKrUNBVEnq0V1InVF9o0kDpZfUUaB5ajNDMb +hF4DMiMKov7/CVgSAQdA7ib6HK7UVW2IdAM5PILbtqzuynukooXdg9YWWamjal8w +h/L0BxbtUFzI3U847dvzwtsW7A9CZXpi45OMXRC9mO3G/FAY1fW+C6PwObT7X1qK +0sDSAbyYddCihL1UEl6EigtD/YQezCTCydzstD2cRdV75lmUqzMCR8NbfvdttBbl +g04ZZtoHm1DSnXIAN4iFzW4EBgNVUDCnqO8wyYmWw4OCOt45IkTXGQUICGxeVh6g +4Pdj4OzSrg/mTZGIDjE2h/osVpPEQjwcAus73EZC4sz4n2U+OMHsq0M54Ds7wyGv +1KBzcQQ5Rc/DPPT0INnl58datm2HGKtWFWYab9/cw/fnQ+oYhmpkZbdITTmnTAyn +CHih86i0w6gXsX99iRLe6yP8BcU3rCCflsCp5zzlcFYViTPgEn1W8H32VOXulfcV +rpRFtc5yzjFj8Dvq9gI9yGIgqiS7oEAXdRBYhmBRTydMcbS1bCPDWy5w95Cp2VzR +x8cBq5e+27QTl9YNHr4xeric7del79wktJFiINzUCQPwkwM9ud2EJ1zN8Owr2Dp8 +JvS2ioSEK5J2Lc95vDLmbVnMuKmVW6qD+fEdtYSHnSIc/t+iYG3GlaFyvO8t0Jav +cbsijrbYhoSOq67KseQWvfL+gL5u +=WWqc +-----END PGP MESSAGE-----"#, (true, true))] + #[case::with_comment(r#"-----BEGIN PGP MESSAGE----- +Version: 1 +wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg +O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae +8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI +JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no +lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz +ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM +YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA +kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI ++lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg +RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo +tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7 +rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp +H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI +fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9 +61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN +XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3 +w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb +NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs +baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW +A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8 +uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI +E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn +lkOWnEbCD+XTnbDd +=agR5 +-----END PGP MESSAGE-----"#, (false, true))] + #[case::invalid_base64(r#"-----BEGIN PGP MESSAGE----- + +wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg +O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae +8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI +JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no +lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz +ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM +YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA +kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI ++lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg +RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo +tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7 +rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp +H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI +fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9 +61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN +XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3 +w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb +NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs +baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW +A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8 +uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI +E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn +lkOWnEbCD+XTnbDd= +=agR5 +-----END PGP MESSAGE-----"#, (false, false))] + #[case::invalid_non_pgp_base64(r#"-----BEGIN PGP MESSAGE----- + +RGVsdGEgQ2hhdCBpcyBhIHJlbGlhYmxlLCBkZWNlbnRyYWxpemVkIGFuZCBzZWN1cmUgaW5zdGFu +dCBtZXNzYWdpbmcgYXBwLCBhdmFpbGFibGUgZm9yIG1vYmlsZSBhbmQgZGVza3RvcCBwbGF0Zm9y +bXMuCgogICAgSW5zdGFudCBjcmVhdGlvbiBvZiBwcml2YXRlIGNoYXQgcHJvZmlsZXMgd2l0aCBz +ZWN1cmUgYW5kIGludGVyb3BlcmFibGUgY2hhdG1haWwgcmVsYXlzIHRoYXQgb2ZmZXIgaW5zdGFu +dCBtZXNzYWdlIGRlbGl2ZXJ5LCBhbmQgUHVzaCBOb3RpZmljYXRpb25zIGZvciBpT1MgYW5kIEFu +ZHJvaWQgZGV2aWNlcy4KCiAgICBQZXJ2YXNpdmUgbXVsdGktcHJvZmlsZSBhbmQgbXVsdGktZGV2 +aWNlIHN1cHBvcnQgb24gYWxsIHBsYXRmb3JtcyBhbmQgYmV0d2VlbiBkaWZmZXJlbnQgY2hhdG1h +aWwgYXBwcy4KCiAgICBJbnRlcmFjdGl2ZSBpbi1jaGF0IGFwcHMgZm9yIGdhbWluZyBhbmQgY29s +bGFib3JhdGlvbgoKICAgIEF1ZGl0ZWQgZW5kLXRvLWVuZCBlbmNyeXB0aW9uIHNhZmUgYWdhaW5z +dCBuZXR3b3JrIGFuZCBzZXJ2ZXIgYXR0YWNrcy4KCiAgICBGcmVlIGFuZCBPcGVuIFNvdXJjZSBz +b2Z0d2FyZSwgYm90aCBhcHAgYW5kIHNlcnZlciBzaWRlLCBidWlsdCBvbiBJbnRlcm5ldCBTdGFu +ZGFyZHMuCgo= +=4cf0a3 +-----END PGP MESSAGE-----"#, (false, false))] + #[case::invalid_cleartext(r#"-----BEGIN PGP MESSAGE----- + +Definitely not base64 encoded PGP message content. +-----END PGP MESSAGE-----"#, (false, false))] + #[case::invalid_no_begin(r#"-----END PGP MESSAGE-----"#, (false, false))] + #[case::invalid_no_end(r#"-----BEGIN PGP MESSAGE-----"#, (false, false))] + fn test_check_armored_payload(#[case] pgp_message: &str, #[case] expected: (bool, bool)) { + let (expected_outgoing, expected_incoming) = expected; + + let result = check_armored_payload(&pgp_message.replace('\n', "\r\n"), true); + assert_eq!(result, expected_outgoing); + + let result = check_armored_payload(&pgp_message.replace('\n', "\r\n"), false); + assert_eq!(result, expected_incoming); + } +} diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs new file mode 100644 index 00000000..170e5466 --- /dev/null +++ b/filtermail/src/outbound.rs @@ -0,0 +1,217 @@ +//! Module for handling outgoing SMTP messages. + +use crate::config::Config; +use crate::message::{check_encrypted, is_securejoin}; +use crate::smtp_client::SmtpConnectionPool; +use crate::smtp_responses::ENCRYPTION_NEEDED_523; +use crate::smtp_responses::OK_250; +use crate::smtp_server::{SmtpHandler, Transaction}; +use crate::tcp::{TcpConnect, TcpStreamTrait}; +use crate::utils::{build_resolver, extract_address}; +use async_trait::async_trait; +use governor::clock::MonotonicClock; +use governor::middleware::NoOpMiddleware; +use governor::{Quota, RateLimiter}; +use hickory_resolver::TokioResolver; +use mailparse::{MailHeaderMap, parse_mail}; +use std::sync::Arc; + +/// Handler for outgoing SMTP messages. +pub struct OutgoingBeforeQueueHandler { + config: Config, + dns_resolver: Arc, + + // We explicitly use standard MonotonicClock here. + // governor 0.10.4 by default uses "quanta" clock + // if the feature "quanta" is enabled + // and it has a known problem + // of sometimes jumping back in time + // when moved between CPU cores: + // + send_rate_limiter: RateLimiter< + String, + governor::state::keyed::DashMapStateStore, + MonotonicClock, + NoOpMiddleware, + >, + smtp_connection_pool: Arc>, +} + +impl OutgoingBeforeQueueHandler +where + S: TcpStreamTrait + TcpConnect, + S::ConnectionContext: Default, +{ + pub fn new(config: Config) -> Result { + let quota = Quota::per_minute(config.max_user_send_per_minute) + .allow_burst(config.max_user_send_burst_size); + let dns_resolver = Arc::new(build_resolver()?); + let send_rate_limiter = RateLimiter::dashmap_with_clock(quota, MonotonicClock); + Ok(Self { + config, + dns_resolver, + send_rate_limiter, + smtp_connection_pool: SmtpConnectionPool::new(Default::default()), + }) + } +} + +#[async_trait] +impl SmtpHandler for OutgoingBeforeQueueHandler +where + S: TcpStreamTrait + TcpConnect, +{ + type State = (); + + fn handle_mail_from(&self, address: &str) -> Result<(), String> { + log::debug!("handle_MAIL from {address}"); + + let parts: Vec<&str> = address.split('@').collect(); + if parts.len() != 2 { + return Err(format!("500 Invalid from address <{}>", address)); + } + + if let Err(e) = self.send_rate_limiter.check_key(&address.to_string()) { + // " rate limited until: ..." + log::debug!("<{address}> {e}"); + return Err(format!("450 4.7.1: Too much mail from <{address}>, {e}")); + } + + // Cleanup + // + // This is only called after a successful check, + // so a spam of mails from the same user will not cause calling this repeatedly. + // In the future, in case of higher traffic this can be further optimized by e.g. calling it + // every N messages or in a separate task every N minutes. + // Time complexity is O(n) where n is the number of unique senders in the last minute. + self.send_rate_limiter.retain_recent(); + + Ok(()) + } + + async fn check_data(&self, transaction: &mut Transaction) -> Result<(), String> { + let message = match parse_mail(&transaction.envelope.data) { + Ok(m) => m, + Err(e) => return Err(format!("500 Failed to parse message: {}", e)), + }; + + let mail_encrypted = check_encrypted(&message, true); + + let from_header = message + .headers + .get_first_value("From") + .unwrap_or_default() + .trim() + .to_string(); + + let from_addr = extract_address(&from_header) + .ok_or(format!("500 Invalid FROM header: {from_header}"))?; + + transaction.envelope.rcpt_to = transaction + .envelope + .rcpt_to + .iter() + .filter(|s| { + let disabled = self.config.is_disabled(s); + if disabled { + log::warn!("Disabled recipient: {s}; removing from RCPT TO"); + } + !disabled + }) + .cloned() + .collect(); + + // MAIL FROM is our source of truth for outbound messages, + // as this address is checked by postfix against the username before sending it + // to filtermail. + log::debug!( + "Processing DATA message from {}", + transaction.envelope.mail_from + ); + + if !transaction + .envelope + .mail_from + .eq_ignore_ascii_case(&from_addr) + { + return Err(format!( + "500 Invalid FROM <{}> for <{}>", + from_addr, transaction.envelope.mail_from + )); + } + + // Allow encrypted or securejoin messages + if mail_encrypted || is_securejoin(&message) { + log::info!("Outgoing: Filtering encrypted mail."); + return Ok(()); + } + + log::info!("Outgoing: Filtering unencrypted mail."); + + // Allow self-sent Autocrypt Setup Message + if transaction.envelope.rcpt_to.len() == 1 + && let Some(rcpt_to) = transaction.envelope.rcpt_to.first() + && *rcpt_to == from_addr + { + let subject = message + .headers + .get_first_value("Subject") + .unwrap_or_default(); + if subject == "Autocrypt Setup Message" && message.ctype.mimetype == "multipart/mixed" { + return Ok(()); + } + } + + log::warn!("Rejected unencrypted mail from: {from_addr}"); + Err(ENCRYPTION_NEEDED_523.to_string()) + } + + async fn reinject_mail(&self, transaction: &Transaction) -> Result<(), String> { + log::debug!("Re-injecting the mail that passed checks"); + let hostname = format!("[{}]", self.config.filtermail_host); + let client_config = crate::smtp_client::ClientConfig { + client_hostname: &hostname, + tls_config: None, + lmtp: false, + }; + crate::smtp_client::send( + &self.config.postfix_host, + self.config.postfix_reinject_port, + &transaction.envelope, + client_config, + self.dns_resolver.clone(), + self.smtp_connection_pool.clone(), + ) + .await + .map_err(|e| { + log::warn!("Failed to re-inject mail: {}", e); + e.smtp_response() + })?; + + Ok(()) + } + + async fn handle_data_dot( + &self, + transaction: &mut Transaction, + ) -> Result { + log::debug!("handle_DATA before-queue"); + self.check_data(transaction).await?; + if self.config.is_disabled(&transaction.envelope.mail_from) { + log::warn!( + "Dropping mail; Sender {} is disabled.", + transaction.envelope.mail_from + ); + return Ok(OK_250.to_string()); + } + if transaction.envelope.rcpt_to.is_empty() { + log::warn!("Dropping mail; All recipients disabled."); + return Ok(OK_250.to_string()); + } + self.reinject_mail(transaction).await.map_err(|e| { + log::warn!("Failed to reinject mail: {e}"); + e + })?; + Ok(OK_250.to_string()) + } +} diff --git a/filtermail/src/smtp_client.rs b/filtermail/src/smtp_client.rs new file mode 100644 index 00000000..bff3d874 --- /dev/null +++ b/filtermail/src/smtp_client.rs @@ -0,0 +1,534 @@ +use crate::smtp_server::Envelope; +use crate::tcp::{TcpConnect, TcpStreamTrait}; +use hickory_resolver::TokioResolver; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufStream}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::task::{JoinHandle, JoinSet}; +use tokio_io_timeout::TimeoutStream; +use tokio_rustls::rustls::client::ClientSessionMemoryCache; + +/// Wraps SMTP connection, contains stream and ESMTP support information. +pub struct SmtpConnection { + pub stream: BufStream>, + pub pipelining: bool, +} + +/// A connection pool for SMTP connections, keyed by (address, port). +/// +/// Connections are cached for up to 100 seconds of idle time. +/// +/// Only a single connection is cached per address/port pair. +pub struct SmtpConnectionPool +where + S: TcpStreamTrait + TcpConnect, +{ + pool: Arc>>, + monitor_handle: JoinHandle<()>, + context: S::ConnectionContext, +} + +impl SmtpConnectionPool +where + S: TcpStreamTrait + TcpConnect, +{ + /// Creates a new connection pool and starts the cache monitoring task. + pub fn new(context: S::ConnectionContext) -> Arc { + let pool = Arc::new(retainer::Cache::new()); + let pool_clone = pool.clone(); + + let monitor_handle = + tokio::spawn(async move { pool_clone.monitor(4, 0.25, Duration::from_secs(10)).await }); + + Arc::new(Self { + pool, + monitor_handle, + context, + }) + } + + /// Takes a connection from the pool for the given address and port, if available. + pub async fn take(&self, address: &str, port: u16) -> Option> { + self.pool.remove(&(address.to_string(), port)).await + } + + /// Puts a connection into the pool for the given address and port, with a 100s timeout. + pub async fn put(&self, address: &str, port: u16, connection: SmtpConnection) { + // similarly to postfix default -> 100s max idle time. + self.pool + .insert( + (address.to_string(), port), + connection, + Duration::from_secs(100), + ) + .await; + } +} + +impl Drop for SmtpConnectionPool { + fn drop(&mut self) { + self.monitor_handle.abort(); + } +} + +/// A [`TcpStream`] wrapper used for SMTP communication. +#[expect(clippy::large_enum_variant)] +pub enum SmtpStream { + /// A plain TCP stream. + Plain(Pin>>), + /// A TLS-encrypted stream. + Tls(tokio_rustls::TlsStream>>>), +} + +impl SmtpStream { + /// Creates a new plain SMTP stream from a raw TCP stream, + /// with read and write timeouts set to 60 seconds. + fn plain(stream: S) -> Self { + let mut timeout_stream = TimeoutStream::new(stream); + timeout_stream.set_write_timeout(Some(Duration::from_secs(60))); + timeout_stream.set_read_timeout(Some(Duration::from_secs(60))); + Self::Plain(Box::pin(timeout_stream)) + } + + /// Returns the peer address of the underlying TCP stream. + pub fn peer_addr(&self) -> std::io::Result { + match self { + SmtpStream::Plain(stream) => stream.get_ref().peer_addr(), + SmtpStream::Tls(stream) => stream.get_ref().0.get_ref().peer_addr(), + } + } + + /// Formats a peer host, including underlying TCP connection's socket address. + /// + /// Returns either: + /// - `:` if `address` is an IP matching underlying TCP connection. + /// - `
[:]` otherwise. + /// + /// `` is either `` or `[]`. + /// + /// Infallible, fallbacks to `
[?:?]` if peer address is unavailable. + fn format_host(&self, address: &str) -> String { + let socket_addr = self.peer_addr().ok(); + Self::format_host_inner(address, socket_addr) + } + + /// Internal logic of [`SmtpStream::format_host`], only for testing purposes. + fn format_host_inner(address: &str, socket_addr: Option) -> String { + let socket_addr_str = if let Some(socket_addr) = socket_addr { + if socket_addr.ip().to_string().eq_ignore_ascii_case(address) { + return socket_addr.to_string(); + } + socket_addr.to_string() + } else { + "?:?".to_string() + }; + format!("{address}[{socket_addr_str}]") + } +} + +#[derive(Debug, Clone)] +pub struct TlsConfig { + pub(crate) allow_invalid_cert: bool, + pub(crate) session_cache: Arc, +} + +impl AsyncWrite for SmtpStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut() { + SmtpStream::Plain(stream) => Pin::new(stream).poll_write(cx, buf), + SmtpStream::Tls(stream) => Pin::new(stream).poll_write(cx, buf), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + SmtpStream::Plain(stream) => Pin::new(stream).poll_flush(cx), + SmtpStream::Tls(stream) => Pin::new(stream).poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + SmtpStream::Plain(stream) => Pin::new(stream).poll_shutdown(cx), + SmtpStream::Tls(stream) => Pin::new(stream).poll_shutdown(cx), + } + } +} + +impl AsyncRead for SmtpStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + SmtpStream::Plain(stream) => Pin::new(stream).poll_read(cx, buf), + SmtpStream::Tls(stream) => Pin::new(stream).poll_read(cx, buf), + } + } +} + +/// Converts address and port to a list of socket addresses. +/// +/// Performs non-blocking DNS resolution if address is a domain name, +/// or returns a single socket address if address is an IP. +async fn to_socket_addrs( + address: &str, + port: u16, + dns_resolver: Arc, +) -> Result, crate::error::Error> { + log::trace!("Resolving {address}..."); + if let Ok(ip) = address.parse() { + Ok(vec![std::net::SocketAddr::new(ip, port)]) + } else { + let lookup = dns_resolver.lookup_ip(address).await?; + Ok(lookup + .iter() + .map(|ip| std::net::SocketAddr::new(ip, port)) + .collect()) + } +} + +/// Establishes a TCP connection to the given address and port, trying all resolved IPs in parallel. +async fn establish_tcp_connection( + address: &str, + port: u16, + dns_resolver: Arc, + context: S::ConnectionContext, +) -> Result +where + S: TcpStreamTrait + TcpConnect, +{ + let mut set: JoinSet> = JoinSet::new(); + + let socket_addrs = to_socket_addrs(address, port, dns_resolver).await?; + + for addr in socket_addrs.clone() { + let context_clone = context.clone(); + set.spawn(async move { + log::trace!("SMTP client: connecting to {addr}..."); + let stream: S = + tokio::time::timeout(Duration::from_secs(60), S::connect(addr, context_clone)) + .await??; + stream.set_nodelay(true)?; + Ok(stream) + }); + } + let mut stream: Option = None; + while let Some(result) = set.join_next().await { + match result { + Ok(Ok(s)) => { + stream = Some(s); + break; + } + Ok(Err(e)) => log::debug!("Failed to connect to socket: {e}"), + Err(e) => log::debug!("Failed to join task: {e}"), + } + } + + match stream { + Some(s) => Ok(s), + None => Err(crate::error::Error::ConnectionFailed( + socket_addrs.into_iter().map(|a| a.to_string()).collect(), + )), + } +} + +/// SMTP/LMTP client configuration options. +pub struct ClientConfig<'a> { + /// Client hostname used for greeting + pub client_hostname: &'a str, + + /// If [`Some`], the connection will be upgraded to TLS. + /// The client will fail early if the server does not support STARTTLS. + pub tls_config: Option, + + /// If `true`, switches to `LHLO` greeting and returns per-recipient composite response. + pub lmtp: bool, +} + +/// Sends an email using an SMTP server at `smtp_addr`. +/// If `address` is a domain that resolves to multiple IP addresses, +/// all will be tried in parallel and the first successful connection will be used. +/// +/// `pool` is used to reuse existing connections to the same address and port, if available. +pub async fn send( + address: &str, + port: u16, + envelope: &Envelope, + config: ClientConfig<'_>, + dns_resolver: Arc, + pool: Arc>, +) -> Result<(), crate::error::Error> +where + S: TcpStreamTrait + TcpConnect, +{ + let greeting = if config.lmtp { "LHLO" } else { "EHLO" }; + + let (mut buf_stream, reused, mut pipelining) = + if let Some(connection) = pool.take(address, port).await { + log::debug!( + "Reusing existing connection to {}", + connection.stream.get_ref().format_host(address) + ); + if config.tls_config.is_some() { + // This should never happen, + // assert to make sure we never accidentally use a plain connection while expecting TLS. + assert!( + matches!(connection.stream.get_ref(), SmtpStream::Tls(_)), + "Expected TLS stream from pool, but got plain stream." + ); + } + (connection.stream, true, connection.pipelining) + } else { + let stream = SmtpStream::plain( + establish_tcp_connection(address, port, dns_resolver.clone(), pool.context.clone()) + .await?, + ); + log::debug!("Successfully connected to {}", stream.format_host(address)); + (BufStream::new(stream), false, false) + }; + + let mut response = String::new(); + + macro_rules! smtp_write { + ($command: expr) => { + let command = $command; + let bytes: &[u8] = command.as_ref(); + log::trace!("Sending: {}", String::from_utf8_lossy(bytes)); + buf_stream.write_all(bytes).await?; + buf_stream.flush().await?; + }; + } + + macro_rules! smtp_read { + ($context:expr) => { + response.clear(); + let mut next_line = String::new(); + buf_stream.read_line(&mut next_line).await?; + response.push_str(&next_line); + while let Some(c) = next_line.as_bytes().get(3) + && *c == b'-' + { + next_line.clear(); + buf_stream.read_line(&mut next_line).await?; + response.push_str(&next_line); + } + log::trace!("SMTP response for {}:\n{}", $context, response); + }; + ($context:expr, $expected_code:expr) => {{ + smtp_read!($context); + smtp_expect!($context, $expected_code) + }}; + } + + macro_rules! smtp_expect { + ($context:expr, $expected_code:expr) => { + if !response.starts_with($expected_code) { + Err(crate::error::Error::MailSend { + context: $context.to_string(), + raw_smtp_answer: response.clone(), + host: buf_stream.get_ref().format_host(address), + }) + } else { + Ok(()) + } + }; + } + + macro_rules! smtp_cmd { + ($command:expr, $context:expr, $expected_code:expr) => {{ + smtp_write!($command); + smtp_read!($context, $expected_code) + }}; + } + + // RSET reused connection or fallback to a new connection + let reused = if reused { + smtp_write!(b"RSET\r\n"); + smtp_read!("RSET"); + // We don't want to defer if the connection was closed already by the server. + // This is a special case where we end up reading message sent before we sent RSET. + // e.g.: 421 example.org Service closing transmission channel - command timeout + if response.starts_with("421") { + log::debug!("Reused connection is dead; establishing new connection..."); + let stream: S = + establish_tcp_connection(address, port, dns_resolver, pool.context.clone()).await?; + log::debug!("Successfully connected to {}", stream.peer_addr()?); + buf_stream = BufStream::new(SmtpStream::plain(stream)); + false + } else { + smtp_expect!("RSET", "250")?; + true + } + } else { + false + }; + + if !reused { + // Read initial greeting + smtp_read!("initial greeting", "220")?; + + smtp_cmd!( + format!("{greeting} {}\r\n", { config.client_hostname }), + greeting, + "250" + )?; + + // ESMTP: PIPELINING + if response.to_uppercase().contains("PIPELINING") { + pipelining = true; + log::debug!("Using pipelining"); + } + + // ESMTP: STARTTLS + if let Some(tls_config) = config.tls_config { + if !response.to_uppercase().contains("STARTTLS") { + // TLS was requested, but server doesn't support STARTTLS. + return Err(crate::error::Error::MailSend { + context: "STARTTLS".to_string(), + raw_smtp_answer: response.clone(), + host: buf_stream.get_ref().format_host(address), + }); + } + + log::trace!("Initiating STARTTLS..."); + smtp_cmd!(b"STARTTLS\r\n", "STARTTLS", "220")?; + + let stream = buf_stream.into_inner(); + let raw_tcp = match stream { + SmtpStream::Plain(s) => s, + SmtpStream::Tls(_) => { + unreachable!("This is the first and only place we upgrade to TLS.") + } + }; + + let tls_stream = crate::tls::wrap_rustls( + address, + raw_tcp, + tls_config.session_cache, + tls_config.allow_invalid_cert, + ) + .await?; + + let smtp_stream = SmtpStream::Tls(tls_stream); + + buf_stream = BufStream::new(smtp_stream); + + smtp_cmd!( + format!("EHLO {}\r\n", config.client_hostname), + "EHLO after STARTTLS", + "250" + )?; + } + } + + // MAIL FROM + smtp_write!(format!("MAIL FROM:<{}>\r\n", envelope.mail_from)); + if !pipelining { + smtp_read!("MAIL FROM", "250")?; + } + + // RCPT TO + for rcpt in &envelope.rcpt_to { + smtp_write!(format!("RCPT TO:<{}>\r\n", rcpt)); + if !pipelining { + smtp_read!("RCPT TO", "250")?; + } + } + + // DATA + smtp_write!(b"DATA\r\n"); + if !pipelining { + smtp_read!("DATA", "354")?; + } else { + // We only return first error + let mut error = smtp_read!("MAIL FROM", "250").err(); + + for _ in &envelope.rcpt_to { + let result = smtp_read!("RCPT TO", "250"); + if error.is_none() { + error = result.err() + } + } + + let data_354 = if let Err(e) = smtp_read!("DATA", "354") { + if error.is_none() { + error = Some(e); + } + false + } else { + true + }; + + if let Some(e) = error { + // RFC2920 3.1: + // > If the DATA command was properly rejected the client SMTP can just issue RSET, + // > but if the DATA command was accepted the client SMTP should send a single dot. + if data_354 { + log::warn!( + "Server {} advertised PIPELINING support, \ + but accepted DATA despite error response to at least one \ + previous command in the group: \n\ + {e} \n\ + Sending a single dot (RFC2920 section 3.1).", + buf_stream.get_ref().format_host(address) + ); + smtp_cmd!(b".\r\n", "end of DATA", "250")?; + } + return Err(e); + } + } + + smtp_write!(&envelope.data); + smtp_write!(b".\r\n"); + if config.lmtp { + for _ in 0..envelope.rcpt_to.len() { + smtp_read!("end of DATA", "250")?; + } + } else { + smtp_read!("end of DATA", "250")?; + } + + pool.put( + address, + port, + SmtpConnection { + stream: buf_stream, + pipelining, + }, + ) + .await; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use std::net::SocketAddr; + use tokio::net::TcpStream; + + #[rstest] + #[case::ipv4("192.0.2.0:25".parse().ok(), "192.0.2.0", "192.0.2.0:25")] + #[case::ipv6("[2001:db8::1]:25".parse().ok(), "2001:db8::1", "[2001:db8::1]:25")] + #[case::domain_ipv4("192.0.2.0:25".parse().ok(), "example.org", "example.org[192.0.2.0:25]")] + #[case::domain_ipv6("[2001:db8::1]:25".parse().ok(), "example.org", "example.org[[2001:db8::1]:25]")] + #[case::unknown(None, "example.org", "example.org[?:?]")] + fn test_format_host_inner( + #[case] socket_addr: Option, + #[case] host: &str, + #[case] expected: &str, + ) { + let result = SmtpStream::::format_host_inner(host, socket_addr); + assert_eq!(result, expected); + } +} diff --git a/filtermail/src/smtp_responses.rs b/filtermail/src/smtp_responses.rs new file mode 100644 index 00000000..b290e1a7 --- /dev/null +++ b/filtermail/src/smtp_responses.rs @@ -0,0 +1,7 @@ +pub const OK_250: &str = "250 OK"; +pub const OK_HTTPS_250: &str = "250 OK (HTTPS)"; +pub const OK_SMTP_250: &str = "250 OK (SMTP)"; + +pub const ENCRYPTION_NEEDED_523: &str = "523 Encryption Needed: Invalid Unencrypted Mail"; +pub const LOCAL_ERROR_451: &str = "451 Local error"; +pub const TRANSPORT_BUSY_421: &str = "421 Transport busy"; diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs new file mode 100644 index 00000000..59c7e4a2 --- /dev/null +++ b/filtermail/src/smtp_server.rs @@ -0,0 +1,381 @@ +//! A simplified SMTP server implementation for internal communication. + +use crate::smtp_responses::OK_250; +use crate::utils::{extract_address, log_eml}; +use async_trait::async_trait; +use memchr::{Memchr, memmem}; +use std::fmt::Debug; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufStream}; +use tokio::net::TcpListener; + +/// Represents an SMTP envelope with sender, recipients, and raw message data. +#[derive(Debug, Default, Clone)] +pub struct Envelope { + pub mail_from: String, + pub rcpt_to: Vec, + /// Mail data as transmitted over SMTP/LMTP. + /// + /// Described in . + /// + /// It MUST end with ``, contain no bare `` or `` + /// and have all `.` sequences escaped with `.` according to + /// . + pub data: Vec, +} + +/// Represent an ongoing SMTP transaction. +/// +/// Every new connection starts with an empty envelope and handler state. +/// A RSET command starts a new transaction, which clears the envelope and state. +#[derive(Debug, Default)] +pub struct Transaction { + pub envelope: Envelope, + pub state: S, +} + +/// Checks if mail data is valid. +fn is_valid_data(data: &[u8]) -> bool { + // DATA must end with . + // + // Otherwise it is not possible to reinject it as is into SMTP/LMTP + // without adding at the end and modifying the message. + if !data.ends_with(b"\r\n") { + return false; + } + + // Check for bare `` and ``. + // + for pos in Memchr::new(b'\r', data) { + if data.get(pos + 1) != Some(&b'\n') { + return false; + } + } + for pos in Memchr::new(b'\n', data) { + if pos == 0 || data.get(pos - 1) != Some(&b'\r') { + return false; + } + } + + // Do not allow unescaped `.`. + if data.starts_with(b".\r\n") || memmem::find(data, b"\r\n.\r\n").is_some() { + return false; + } + + true +} + +/// Trait defining the SMTP handler interface. +#[async_trait] +pub trait SmtpHandler: Send + Sync { + /// Transaction state type associated with this handler. + type State: Debug + Default + Send; + + /// Checks the DATA command before reinjection. + /// + /// Can optionally modify the envelope before reinjection. + /// + /// Default implementation is no-op. + async fn check_data(&self, _transaction: &mut Transaction) -> Result<(), String> { + Ok(()) + } + + /// Reinjects the mail back to postfix. + /// + /// Default implementation is no-op. + async fn reinject_mail(&self, _transaction: &Transaction) -> Result<(), String> { + Ok(()) + } + + /// Handles the MAIL FROM command. + /// + /// Default implementation is no-op. + fn handle_mail_from(&self, _address: &str) -> Result<(), String> { + Ok(()) + } + + /// Handles the RCPT TO command. + /// + /// Default implementation is no-op. + fn handle_rcpt_to( + &self, + _address: &str, + _transaction: &mut Transaction, + ) -> Result<(), String> { + Ok(()) + } + + /// Handles the DATA command. Called after receiving DATA, before receiving actual data. + /// + /// Default implementation is no-op. + fn handle_data_start(&self, _transaction: &Transaction) -> Result<(), String> { + Ok(()) + } + + /// Handles the end of DATA command. Called after receiving the final dot. + async fn handle_data_dot( + &self, + transaction: &mut Transaction, + ) -> Result { + log::debug!("handle_DATA before-queue"); + + // Check if the DATA is valid + // before doing any custom checks. + // + // We are not going to normalize newlines + // and escape the dots in the mail data. + // If mail data turned out to be invalid, reject immediately. + if !is_valid_data(&transaction.envelope.data) { + return Err("500 Invalid DATA".to_string()); + } + + self.check_data(transaction).await?; + if transaction.envelope.rcpt_to.is_empty() { + log::warn!("Dropping mail; All recipients disabled."); + return Ok(OK_250.to_string()); + } + self.reinject_mail(transaction).await.map_err(|e| { + log::warn!("Failed to reinject mail: {e}"); + e + })?; + Ok(OK_250.to_string()) + } +} + +/// A mockup handler that does nothing. +#[cfg(test)] +pub struct MockHandler; + +#[cfg(test)] +impl SmtpHandler for MockHandler { + type State = (); +} + +/// Runs the SMTP server on the specified address with the given handler and maximum message size. +pub async fn run_smtp_server( + addr: &impl tokio::net::ToSocketAddrs, + handler: Arc, + max_size: usize, +) -> Result<(), crate::error::Error> +where + H: SmtpHandler + 'static, +{ + let listener = TcpListener::bind(addr).await?; + // message for backward compatibility with chatmaild tests. + log::info!("entering serving loop"); + + loop { + match listener.accept().await { + Ok((socket, _peer_addr)) => { + // Disable Nagle's algorithm. + socket.set_nodelay(true)?; + + let handler = handler.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(socket, handler, max_size, false).await { + log::error!("Error handling connection: {e}"); + } + }); + } + Err(e) => { + log::error!("Error accepting connection: {e}"); + + // Sleep to avoid busy looping in case we ran into file descriptor limit. + tokio::time::sleep(Duration::from_secs(10)).await; + } + } + } +} + +/// Handles an individual SMTP connection. +/// +/// Setting `auto_quit` to `true` will automatically close connection after receiving the first +/// message. Should be used only for tests, it's not a behavior described by SMTP spec. +pub(crate) async fn handle_connection( + stream: S, + handler: Arc, + max_size: usize, + auto_quit: bool, +) -> Result<(), Box> +where + S: AsyncWrite + AsyncRead + Unpin, + H: SmtpHandler, +{ + let mut bufstream = BufStream::new(stream); + let mut line = String::new(); + + macro_rules! smtp_write { + ($($arg:tt)*) => { + bufstream.write_all(format!($($arg)*).as_bytes()).await?; + bufstream.flush().await?; + }; + } + + smtp_write!("220 filtermail SMTP\r\n"); + + let mut transaction = Transaction::default(); + + 'connection: loop { + line.clear(); + let n = bufstream.read_line(&mut line).await?; + if n == 0 { + break 'connection; + } + + // Remove CRLF + // Note: this will kill the connection if any line doesn't end with CRLF. + // This is intentional as stray LF most likely means an attempt to exploit the server. + let Some(cmd) = line.strip_suffix("\r\n") else { + log::warn!( + "Malformed command without CRLF ending! Received: {line:?} Closing connection." + ); + break 'connection; + }; + + log::debug!("Received: {cmd}"); + + if cmd.to_uppercase().starts_with("HELO") { + smtp_write!("250-filtermail\r\n250 OK\r\n"); + } else if cmd.to_uppercase().starts_with("EHLO") + // We support LMTP, but it's not validated; + // service that expects LMTP will send LMTP responses no matter the greeting. + // Sufficient for our internal use case. + || cmd.to_uppercase().starts_with("LHLO") + { + smtp_write!("250-filtermail\r\n250-8BITMIME\r\n250 OK\r\n"); + } else if cmd.to_uppercase().starts_with("MAIL FROM:<>") { + // bounce message + transaction.envelope.mail_from = String::new(); + smtp_write!("{OK_250}\r\n"); + } else if cmd.to_uppercase().starts_with("MAIL FROM:") { + if let Some(from) = extract_address(cmd) { + if let Err(e) = handler.handle_mail_from(&from) { + smtp_write!("{}\r\n", e); + continue 'connection; + } + transaction.envelope.mail_from = from; + smtp_write!("{OK_250}\r\n"); + } else { + log::warn!("Invalid MAIL FROM command. Can't extract address. Received: {cmd}"); + smtp_write!("500 Invalid address in MAIL FROM\r\n"); + } + } else if cmd.to_uppercase().starts_with("RCPT TO:") { + if let Some(to) = extract_address(cmd) { + if let Err(e) = handler.handle_rcpt_to(&to, &mut transaction) { + smtp_write!("{}\r\n", e); + continue 'connection; + } + transaction.envelope.rcpt_to.push(to); + smtp_write!("{OK_250}\r\n"); + } + } else if cmd.to_uppercase().starts_with("DATA") { + if let Err(e) = handler.handle_data_start(&transaction) { + smtp_write!("{}\r\n", e); + continue 'connection; + } + smtp_write!("354 End data with .\r\n"); + let mut data = Vec::new(); + let mut data_line = String::new(); + 'data_read: loop { + data_line.clear(); + if bufstream.read_line(&mut data_line).await? == 0 { + log::warn!("Unexpected EoF while receiving DATA! Closing connection."); + break 'connection; + } + + if data_line == ".\r\n" { + break 'data_read; + } + + if !data_line.ends_with("\r\n") { + log::warn!("Malformed DATA line without CRLF ending! Closing connection."); + data.extend_from_slice(data_line.as_bytes()); + let eml_path = log_eml("malformed-data", &data) + .await + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|e| { + log::error!("Failed to save rejected message to file: {e}"); + "ERR".to_string() + }); + log::info!("Rejected message stored at: {eml_path}"); + break 'connection; + } + + data.extend_from_slice(data_line.as_bytes()); + + if data.len() > max_size { + smtp_write!("552 Message exceeds maximum size\r\n"); + continue 'connection; + } + } + + transaction.envelope.data = data; + + // Process the message + match handler.handle_data_dot(&mut transaction).await { + Ok(response) => { + log::debug!("Sent: {response}"); + smtp_write!("{}\r\n", response); + } + Err(e) => { + log::debug!("Sent: {e}"); + smtp_write!("{}\r\n", e); + } + } + if auto_quit { + break 'connection; + } + transaction = Transaction::default(); + } else if cmd.to_uppercase().starts_with("QUIT") { + smtp_write!("221 OK\r\n"); + break 'connection; + } else if cmd.to_uppercase().starts_with("RSET") { + transaction = Transaction::default(); + smtp_write!("{OK_250}\r\n"); + } else if cmd.to_uppercase().starts_with("NOOP") { + smtp_write!("{OK_250}\r\n"); + } else { + smtp_write!("500 Command not recognized\r\n"); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::*; + + #[rstest] + #[case(b"", false)] + #[case(b".", false)] + #[case(b"Hello!\n", false)] + #[case(b"Hello!\n\r", false)] + #[case(b"Hello\nworld!\r\n", false)] + #[case(b"Hello!\r\n\n", false)] + #[case(b"Hello\r\n.\r\n", false)] + #[case(b"Hello!\r\n .\r\n", true)] + #[case(b"Hello!\r\n..\r\n", true)] + #[case(b"Hello!\r\n", true)] + #[case(b"Hello\r\n.world\r\n", true)] + #[case(b"Hello!\r\r\n", false)] + #[case(b"Hello!\r\r\n\n", false)] + #[case(b"Hello\rworld!\r\n", false)] + #[case(b"\n", false)] + #[case(b"\nHello\r\n", false)] + #[case(b"\r", false)] + #[case(b".\r\n", false)] + #[case(b".\r\nHello\r\n", false)] + #[case(b"..\r\n.\r\n", false)] + #[case(b".\r\n..\r\n", false)] + #[case(b"\r\n.\r\n", false)] + #[case(b"..\r\n..\r\n", true)] + #[case(b" .\r\n", true)] + #[case(b"..\r\n", true)] + #[case(b"\r\n", true)] + fn test_is_valid_data(#[case] data: &[u8], #[case] expected: bool) { + assert_eq!(is_valid_data(data), expected, "{data:?}"); + } +} diff --git a/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail.snap b/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail.snap new file mode 100644 index 00000000..46bc4567 --- /dev/null +++ b/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail.snap @@ -0,0 +1,64 @@ +--- +source: src/transport.rs +expression: "format!(\"[postfix -> filtermail-transport]\\r\\n{record}\\r\\n\\\n [filtermail-transport -> destination A]\\r\\n{}\\r\\n\\\n [filtermail-transport -> destination B]\\r\\n{}\",\nremote_records[0], remote_records[1])" +--- +[postfix -> filtermail-transport] +< 220 filtermail SMTP +> LHLO postfix +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> RCPT TO: +< 250 OK +> RCPT TO: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +> . +< 250 OK (SMTP) +250 OK (SMTP) +250 OK (SMTP) +250 OK (SMTP) + +[filtermail-transport -> destination A] +< 220 filtermail SMTP +> EHLO example.org +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +. +< 250 OK + +[filtermail-transport -> destination B] +< 220 filtermail SMTP +> EHLO example.org +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +. +< 250 OK diff --git a/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_defer.snap b/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_defer.snap new file mode 100644 index 00000000..01244c79 --- /dev/null +++ b/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_defer.snap @@ -0,0 +1,73 @@ +--- +source: src/transport.rs +expression: "format!(\"TRANSACTION 1\\r\\n\\\n [postfix -> filtermail-transport]\\r\\n{record_postfix_1}\\r\\n\\\n [filtermail-transport -> destination A]\\r\\n{record_filtermail_1}\\r\\n\\r\\n\\\n TRANSACTION 2\\r\\n\\\n [postfix -> filtermail-transport]\\r\\n{record_postfix_2}\\r\\n\\\n [filtermail-transport -> destination B]\\r\\n{record_filtermail_2}\",)" +--- +TRANSACTION 1 +[postfix -> filtermail-transport] +< 220 filtermail SMTP +> LHLO postfix +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +> . +< 250 OK (SMTP) +421 Transport busy + +[filtermail-transport -> destination A] +< 220 filtermail SMTP +> EHLO example.org +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +. +< 250 OK + + +TRANSACTION 2 +[postfix -> filtermail-transport] +< 220 filtermail SMTP +> LHLO postfix +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +> . +< 250 OK (SMTP) + +[filtermail-transport -> destination B] +< 220 filtermail SMTP +> EHLO example.org +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +. +< 250 OK diff --git a/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_worker_cap_exceeded.snap b/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_worker_cap_exceeded.snap new file mode 100644 index 00000000..4c37501d --- /dev/null +++ b/filtermail/src/snapshots/filtermail__transport__tests__smtp_send_mail_worker_cap_exceeded.snap @@ -0,0 +1,51 @@ +--- +source: src/transport.rs +expression: "format!(\"TRANSACTION 1\\r\\n\\\n [postfix -> filtermail-transport]\\r\\n{record_postfix_1}\\r\\n\\\n [filtermail-transport -> destination A]\\r\\n{record_filtermail_1}\\r\\n\\r\\n\\\n TRANSACTION 2\\r\\n\\\n [postfix -> filtermail-transport]\\r\\n{record_postfix_2}\")" +--- +TRANSACTION 1 +[postfix -> filtermail-transport] +< 220 filtermail SMTP +> LHLO postfix +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +> . +< 250 OK (SMTP) + +[filtermail-transport -> destination A] +< 220 filtermail SMTP +> EHLO example.org +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 354 End data with . +> message +. +< 250 OK + + +TRANSACTION 2 +[postfix -> filtermail-transport] +< 220 filtermail SMTP +> LHLO postfix +< 250-filtermail +250-8BITMIME +250 OK +> MAIL FROM: +< 250 OK +> RCPT TO: +< 250 OK +> DATA +< 421 Transport busy diff --git a/filtermail/src/tcp.rs b/filtermail/src/tcp.rs new file mode 100644 index 00000000..dbc74182 --- /dev/null +++ b/filtermail/src/tcp.rs @@ -0,0 +1,59 @@ +//! TCP related code. + +use async_trait::async_trait; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::net::{TcpStream, ToSocketAddrs}; + +#[cfg(test)] +pub mod rec_stream; + +/// Abstraction over [`TcpStream`] allowing e.g. mocking it. +pub trait TcpStreamTrait: AsyncRead + AsyncWrite + Unpin + Send + Sync + Sized + 'static { + /// Returns the remote address that this stream is connected to. + fn peer_addr(&self) -> std::io::Result; + + /// Sets the value of the `TCP_NODELAY` option on this socket. + fn set_nodelay(&self, nodelay: bool) -> std::io::Result<()>; +} + +/// Trait adding a `connect` method similar to [`TcpStream::connect`], +/// that allows passing additional context when creating the stream. +#[async_trait] +pub trait TcpConnect: TcpStreamTrait { + /// Type of additional context passed to [`TcpConnect::connect`]. + type ConnectionContext: Send + Sync + Clone + 'static; + + /// Opens a TCP connection to a remote host. + async fn connect( + addr: A, + context: Self::ConnectionContext, + ) -> std::io::Result; +} + +impl TcpStreamTrait for TcpStream { + /// Returns the remote address that this stream is connected to. + /// + /// Delegates to [`TcpStream::peer_addr`]. + fn peer_addr(&self) -> std::io::Result { + TcpStream::peer_addr(self) + } + + /// Sets the value of the `TCP_NODELAY` option on this socket. + /// + /// Delegates to [`TcpStream::set_nodelay`]. + fn set_nodelay(&self, nodelay: bool) -> std::io::Result<()> { + TcpStream::set_nodelay(self, nodelay) + } +} + +#[async_trait] +impl TcpConnect for TcpStream { + type ConnectionContext = (); + + /// Opens a TCP connection to a remote host. + /// + /// Delegates to [`TcpStream::connect`]. + async fn connect(addr: A, _: ()) -> std::io::Result { + TcpStream::connect(addr).await + } +} diff --git a/filtermail/src/tcp/rec_stream.rs b/filtermail/src/tcp/rec_stream.rs new file mode 100644 index 00000000..7ae8240d --- /dev/null +++ b/filtermail/src/tcp/rec_stream.rs @@ -0,0 +1,127 @@ +//! [`TcpStreamTrait`] implementation that records communication. +//! +//! Used for snapshot testing. + +use super::{TcpConnect, TcpStreamTrait}; +use async_trait::async_trait; +use std::net::SocketAddr; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::{TcpStream, ToSocketAddrs}; +use tokio::sync::mpsc::Sender; + +/// A stream that behaves similarly to [`TcpStream`], +/// but additionally records the whole conversation to internal buffer, +/// and sends it over [`Sender`] when dropped. +pub struct RecTcpStream { + tx: Sender, + inner: TcpStream, + rec_buffer: String, + // read/write arrows + arrows: (char, char), +} + +impl RecTcpStream { + /// Creates a new [`RecTcpStream`]. + /// + /// Recorded conversation will be sent over `tx`. + /// + /// Setting `is_server` to `true`, will invert read/write arrow characters, + /// so that they correctly used for `> client command` and `< server response`. + pub fn new(stream: TcpStream, tx: Sender, is_server: bool) -> Self { + Self { + tx, + inner: stream, + rec_buffer: String::new(), + arrows: match is_server { + true => ('>', '<'), + false => ('<', '>'), + }, + } + } +} + +impl AsyncWrite for RecTcpStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let mut_self = self.get_mut(); + let inner_result = Pin::new(&mut mut_self.inner).poll_write(cx, buf); + if inner_result.is_pending() { + return inner_result; + } + + if !buf.is_empty() { + let mut data = String::from_utf8_lossy(buf).to_string(); + data = format!("{} {data}", mut_self.arrows.1); + mut_self.rec_buffer.push_str(&data); + } + + inner_result + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) + } +} + +impl AsyncRead for RecTcpStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let mut_self = self.get_mut(); + let inner_result = Pin::new(&mut mut_self.inner).poll_read(cx, buf); + if inner_result.is_pending() { + return inner_result; + } + + let filled = buf.filled(); + if !filled.is_empty() { + let mut data = String::from_utf8_lossy(filled).to_string(); + data = format!("{} {data}", mut_self.arrows.0); + mut_self.rec_buffer.push_str(&data); + } + + inner_result + } +} + +impl Drop for RecTcpStream { + fn drop(&mut self) { + let tx = self.tx.clone(); + let rec_buffer = self.rec_buffer.clone(); + tokio::spawn(async move { tx.send(rec_buffer).await.unwrap() }); + } +} + +impl TcpStreamTrait for RecTcpStream { + fn peer_addr(&self) -> std::io::Result { + self.inner.peer_addr() + } + + fn set_nodelay(&self, nodelay: bool) -> std::io::Result<()> { + self.inner.set_nodelay(nodelay) + } +} + +#[async_trait] +impl TcpConnect for RecTcpStream { + type ConnectionContext = Sender; + + async fn connect( + addr: A, + tx: Sender, + ) -> std::io::Result { + let inner = TcpStream::connect(addr).await?; + Ok(Self::new(inner, tx, false)) + } +} diff --git a/filtermail/src/tls.rs b/filtermail/src/tls.rs new file mode 100644 index 00000000..a9b5af1f --- /dev/null +++ b/filtermail/src/tls.rs @@ -0,0 +1,57 @@ +//! TLS support. +use std::sync::Arc; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio_rustls::rustls::client::ClientSessionMemoryCache; +use tokio_rustls::{TlsStream, rustls}; + +mod danger; +use danger::NoCertificateVerification; + +pub async fn wrap_rustls( + hostname: &str, + stream: IO, + resumption_store: Arc, + dangerous_no_cert_verification: bool, +) -> Result, crate::error::Error> +where + IO: AsyncRead + AsyncWrite + Unpin, +{ + let config = configure_rustls(resumption_store, dangerous_no_cert_verification)?; + let tls = tokio_rustls::TlsConnector::from(Arc::new(config)); + let name = rustls::pki_types::ServerName::try_from(hostname)?.to_owned(); + let tls_stream = tls.connect(name, stream).await?; + Ok(tls_stream.into()) +} + +pub fn configure_rustls( + resumption_store: Arc, + dangerous_no_cert_verification: bool, +) -> Result { + let root_cert_store = + rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + + let mut config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::aws_lc_rs::default_provider(), + )) + .with_safe_default_protocol_versions()? + .with_root_certificates(root_cert_store) + .with_no_client_auth(); + + // Enable TLS 1.3 session resumption + // as defined in . + // + // Obsolete TLS 1.2 mechanisms defined in RFC 5246 + // and RFC 5077 have worse security + // and are not worth increasing + // attack surface: . + config.resumption = rustls::client::Resumption::store(resumption_store) + .tls12_resumption(rustls::client::Tls12Resumption::Disabled); + + if dangerous_no_cert_verification { + config + .dangerous() + .set_certificate_verifier(Arc::new(NoCertificateVerification::default())); + } + + Ok(config) +} diff --git a/filtermail/src/tls/danger.rs b/filtermail/src/tls/danger.rs new file mode 100644 index 00000000..1b9a9ffd --- /dev/null +++ b/filtermail/src/tls/danger.rs @@ -0,0 +1,49 @@ +//! Dangerous TLS implementation of accepting invalid certificates for Rustls. + +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use tokio_rustls::rustls; + +#[derive(Debug, Default)] +pub(super) struct NoCertificateVerification(); + +impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + let provider = rustls::crypto::aws_lc_rs::default_provider(); + let supported_schemes = &provider.signature_verification_algorithms; + rustls::crypto::verify_tls12_signature(message, cert, dss, supported_schemes) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + let provider = rustls::crypto::aws_lc_rs::default_provider(); + let supported_schemes = &provider.signature_verification_algorithms; + rustls::crypto::verify_tls13_signature(message, cert, dss, supported_schemes) + } + + fn supported_verify_schemes(&self) -> Vec { + let provider = rustls::crypto::aws_lc_rs::default_provider(); + provider + .signature_verification_algorithms + .supported_schemes() + } +} diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs new file mode 100644 index 00000000..cd79b8a3 --- /dev/null +++ b/filtermail/src/transport.rs @@ -0,0 +1,502 @@ +mod https_client; +mod worker; + +use crate::config::Config; +use crate::smtp_responses::{LOCAL_ERROR_451, TRANSPORT_BUSY_421}; +use crate::smtp_server::{SmtpHandler, Transaction}; +use crate::tcp::{TcpConnect, TcpStreamTrait}; +use crate::utils::AddressDomain; +use async_trait::async_trait; +use std::collections::BTreeMap; +use std::str::FromStr; +use tokio::sync::mpsc::OwnedPermit; +use tokio::task::JoinSet; +use worker::{WorkerMessage, WorkerPool}; + +pub const HEADER_MAIL_FROM: &str = "X-MAIL-FROM"; +pub const HEADER_RCPT_TO: &str = "X-MAIL-TO"; + +pub struct TransportHandler { + workers: WorkerPool, +} + +impl TransportHandler +where + S: TcpStreamTrait + TcpConnect, + S::ConnectionContext: Default, +{ + /// Creates a new [`TransportHandler`]. + pub fn new(config: Config) -> Result { + let workers = WorkerPool::new(config)?; + + Ok(Self { workers }) + } + + /// Same as [`Self::new`], but lets you set worker queue size and pool capacity. + /// + /// Only used for tests. + #[cfg(test)] + pub fn with_queue_size_and_pool_capacity( + config: Config, + queue_size: usize, + pool_capacity: usize, + ) -> Result { + let workers = + WorkerPool::with_queue_size_and_pool_capacity(config, queue_size, pool_capacity)?; + + Ok(Self { workers }) + } +} + +#[derive(Debug, Default)] +pub struct TransactionState { + permits: BTreeMap>, +} + +#[async_trait] +impl SmtpHandler for TransportHandler +where + S: TcpStreamTrait + TcpConnect, + S::ConnectionContext: Default, +{ + type State = TransactionState; + + fn handle_rcpt_to( + &self, + address: &str, + transaction: &mut Transaction, + ) -> Result<(), String> { + let domain = AddressDomain::from_str(address).map_err(|e| e.smtp_response())?; + + if transaction.state.permits.contains_key(&domain) { + // We already acquired a permit for this domain + return Ok(()); + } + + log::trace!("Trying to acquire a permit for {domain} worker...",); + if let Some(permit) = self.workers.get_permit(&domain) { + transaction.state.permits.insert(domain, permit); + } + + Ok(()) + } + + fn handle_data_start(&self, transaction: &Transaction) -> Result<(), String> { + // We want to prevent needlessly sending data from postfix to filtermail, + // so we fail here if we didn't get any permit. + // + // Examplary scenario: + // Consider destinations A and B, where A is unavailable. + // We are sending a message to a group of 1@A, 2@A, 1@B, 2@B. + // After handle_rcpt_to on every recipient, we end up with a permit for domain B (A fails). + // handle_data_start passes and mail data is transmitted to filtermail. + // Delivery to B is performed; 1@B and 2@B receive message and a message to 1@A and 2@A + // is deferred. + // After some time the message is retried, now we only try to acquire permit for A, + // but fail -> empty `transaction.state.permits` + // handle_data_start fails and mail data is not sent to filtermail. + // This greatly reduces RAM usage, as unavailable destination can cause large numbers of + // deferred mails to be constantly retried. + + if transaction.state.permits.is_empty() { + return Err(TRANSPORT_BUSY_421.to_string()); + } + + Ok(()) + } + + /// Handles the DATA command and returns LMTP responses as single string. + /// + /// Never returns an error, as LMTP response is composite. + async fn handle_data_dot( + &self, + transaction: &mut Transaction, + ) -> Result { + let mut domain_rcpts_map = BTreeMap::new(); + + for rcpt in &transaction.envelope.rcpt_to { + let domain = AddressDomain::from_str(rcpt) + // Currently we cancel all transactions if any recipient address is invalid. + .map_err(|e| e.lmtp_response(transaction.envelope.rcpt_to.len()))?; + domain_rcpts_map + .entry(domain) + .or_insert_with(Vec::new) + .push(rcpt.to_string()); + } + + // one transaction per domain + let mut transactions = JoinSet::new(); + let mut task_id_domain_map = BTreeMap::new(); + + for (rcpt_domain, rcpts) in &domain_rcpts_map { + let domain_envelope = { + let mut envelope = transaction.envelope.clone(); + envelope.rcpt_to = rcpts.clone(); + envelope + }; + let receiver_task_id = + if let Some(permit) = transaction.state.permits.remove(rcpt_domain) { + let (message, receiver) = WorkerMessage::new(domain_envelope); + permit.send(message); + transactions.spawn(receiver).id() + } else { + transactions + .spawn(async move { Ok(Err(TRANSPORT_BUSY_421.to_string())) }) + .id() + }; + task_id_domain_map.insert(receiver_task_id, rcpt_domain); + } + + let mut rcpt_response_map = BTreeMap::new(); + while let Some(result) = transactions.join_next_with_id().await { + let domain = match &result { + Ok((id, _)) => task_id_domain_map.remove(id), + Err(e) => task_id_domain_map.remove(&e.id()), + }; + + let smtp_response = match result { + Ok((_, Ok(Ok(resp)))) | Ok((_, Ok(Err(resp)))) => resp, + Ok((_, Err(_))) => { + log::error!( + "Delivery to {} failed due to a dead worker!", + domain.map(AsRef::as_ref).unwrap_or("") + ); + if let Some(domain) = domain { + self.workers.cleanup(domain); + } + LOCAL_ERROR_451.to_string() + } + Err(e) => { + log::error!( + "Failed to join task while delivering to {}: {e}", + domain.map(AsRef::as_ref).unwrap_or("") + ); + LOCAL_ERROR_451.to_string() + } + }; + + if let Some(domain) = domain + && let Some(rcpts) = domain_rcpts_map.get(domain) + { + for rcpt in rcpts { + rcpt_response_map.insert(rcpt, smtp_response.clone()); + } + } + } + + // compose lmtp response... + let ordered_responses: Vec = transaction + .envelope + .rcpt_to + .iter() + .map(|rcpt| { + rcpt_response_map + .remove(rcpt) + .unwrap_or_else(|| LOCAL_ERROR_451.to_string()) + }) + .collect(); + + Ok(ordered_responses.join("\r\n")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::smtp_client::SmtpConnectionPool; + use crate::smtp_server::{Envelope, MockHandler, run_smtp_server}; + use crate::tcp::rec_stream::RecTcpStream; + use rstest::{fixture, rstest}; + use serial_test::serial; + use std::sync::Arc; + use std::time::Duration; + use testresult::TestResult; + use tokio::net::{TcpSocket, TcpStream}; + use tokio::sync::mpsc::Receiver; + + const FILTERMAIL_IP: &str = "127.0.0.1"; + const FILTERMAIL_PORT: u16 = 10083; + const FILTERMAIL_ADDR: (&str, u16) = (FILTERMAIL_IP, FILTERMAIL_PORT); + + /// Spawns a mockup SMTP server that accepts anything on `localhost:10025`. + /// + /// Returns a receiver that receives records of SMTP conversations. + fn spawn_mock_mta() -> TestResult> { + let socket = TcpSocket::new_v4()?; + socket.set_nodelay(true)?; + socket.set_reuseport(true)?; + socket.bind("127.0.0.1:10025".parse()?)?; + let remote_listener = socket.listen(8)?; + let (tx, rx) = tokio::sync::mpsc::channel(128); + tokio::spawn(async move { + while let Ok((stream, _)) = remote_listener.accept().await { + let tx_clone = tx.clone(); + let rec_stream = RecTcpStream::new(stream, tx_clone, true); + tokio::spawn(async move { + crate::smtp_server::handle_connection( + rec_stream, + Arc::new(MockHandler), + 9999, // arbitrary + true, + ) + .await + .unwrap(); + }); + } + }); + Ok(rx) + } + + /// Spawns filtermail-transport. + /// + /// Returns a pointer to the underlying handler. + fn spawn_filtermail_transport( + queue_size: usize, + pool_capacity: usize, + ) -> TestResult>> { + let config = Config::default(); + let transport = Arc::new(TransportHandler::with_queue_size_and_pool_capacity( + config.clone(), + queue_size, + pool_capacity, + )?); + tokio::spawn(run_smtp_server( + &FILTERMAIL_ADDR, + transport.clone(), + config.max_message_size, + )); + Ok(transport) + } + + /// Sends envelope over LMTP. + /// + /// Returns a recorded LMTP conversation. + /// + /// Does not fail on negative response. + async fn lmtp_send(envelope: &Envelope) -> TestResult { + let (tx, mut rx) = tokio::sync::mpsc::channel(128); + let client_config = crate::smtp_client::ClientConfig { + client_hostname: "postfix", + tls_config: None, + lmtp: true, + }; + let _ = crate::smtp_client::send( + FILTERMAIL_IP, + FILTERMAIL_PORT, + envelope, + client_config, + Arc::new(crate::utils::build_resolver()?), + SmtpConnectionPool::::new(tx), + ) + .await; + + let record = rx.recv().await.unwrap(); + + Ok(record) + } + + #[fixture] + fn addrs1() -> Vec { + let mut vec = Vec::new(); + for idx in 0..5 { + vec.push(format!("{idx}@one.example.org")) + } + vec + } + + #[fixture] + fn addrs2() -> Vec { + let mut vec = Vec::new(); + for idx in 0..5 { + vec.push(format!("{idx}@two.example.org")) + } + vec + } + + #[rstest] + #[tokio::test] + async fn test_rcpt_to_and_start_data(addrs1: Vec, addrs2: Vec) -> TestResult { + let transport_handler = TransportHandler::::with_queue_size_and_pool_capacity( + Config::default(), + 1, + 500, + )?; + let domain1 = AddressDomain::from_str(addrs1.first().unwrap())?; + let domain2 = AddressDomain::from_str(addrs2.first().unwrap())?; + + { + let mut trans_1 = Transaction::default(); + let mut trans_2 = Transaction::default(); + let mut trans_3 = Transaction::default(); + + transport_handler.handle_rcpt_to(addrs1.first().unwrap(), &mut trans_1)?; + assert!(trans_1.state.permits.contains_key(&domain1)); + + // Within one transaction, we only use one worker permit, so queue_size=1 is enough. + transport_handler.handle_rcpt_to(addrs1.get(1).unwrap(), &mut trans_1)?; + assert!(trans_1.state.permits.contains_key(&domain1)); + + // However, a second transaction with the same domain won't get a permit. + transport_handler.handle_rcpt_to(addrs1.get(2).unwrap(), &mut trans_2)?; + assert!(!trans_2.state.permits.contains_key(&domain1)); + + // Different domain will work though, as it uses a separate worker, with its own queue. + transport_handler.handle_rcpt_to(addrs2.first().unwrap(), &mut trans_2)?; + assert!(trans_2.state.permits.contains_key(&domain2)); + + // Third transaction won't get any permits. + transport_handler.handle_rcpt_to(addrs1.get(3).unwrap(), &mut trans_3)?; + transport_handler.handle_rcpt_to(addrs2.get(2).unwrap(), &mut trans_3)?; + assert!(!trans_3.state.permits.contains_key(&domain1)); + assert!(!trans_3.state.permits.contains_key(&domain2)); + + // all permits granted -> accept DATA command + assert_eq!(transport_handler.handle_data_start(&trans_1), Ok(())); + + // some permits granted -> accept DATA command + assert_eq!(transport_handler.handle_data_start(&trans_2), Ok(())); + + // no permits granted -> reject + assert!(transport_handler.handle_data_start(&trans_3).is_err()); + } + + // Transactions (and owned by them permits) going out of scope frees the queues. + let mut trans_4 = Transaction::default(); + transport_handler.handle_rcpt_to(addrs1.first().unwrap(), &mut trans_4)?; + transport_handler.handle_rcpt_to(addrs2.first().unwrap(), &mut trans_4)?; + assert!(trans_4.state.permits.contains_key(&domain1)); + assert!(trans_4.state.permits.contains_key(&domain2)); + + Ok(()) + } + + #[rstest] + #[serial] + #[tokio::test] + async fn test_smtp_send_mail() -> TestResult { + let mut remote_mta = spawn_mock_mta()?; + spawn_filtermail_transport(1, 2)?; + + let envelope = Envelope { + mail_from: "sender@here".to_string(), + rcpt_to: vec![ + // Taking advantage of the fact that localhost and [127.0.0.1] are recognized as + // different destinations. + "a1@localhost".to_string(), + "a2@localhost".to_string(), + "b1@[127.0.0.1]".to_string(), + "b2@[127.0.0.1]".to_string(), + ], + data: "message\r\n".as_bytes().to_vec(), + }; + + let record = lmtp_send(&envelope).await?; + + let mut remote_records = [ + remote_mta.recv().await.unwrap(), + remote_mta.recv().await.unwrap(), + ]; + remote_records.sort_by_key(|s| s.contains("b1@[127.0.0.1]")); + tokio::time::sleep(Duration::from_secs(1)).await; + assert!(remote_mta.is_empty()); + + insta::assert_snapshot!(format!( + "[postfix -> filtermail-transport]\r\n{record}\r\n\ + [filtermail-transport -> destination A]\r\n{}\r\n\ + [filtermail-transport -> destination B]\r\n{}", + remote_records[0], remote_records[1] + )); + + Ok(()) + } + + #[rstest] + #[serial] + #[tokio::test] + async fn test_smtp_send_mail_defer() -> TestResult { + let mut remote_mta = spawn_mock_mta()?; + let transport = spawn_filtermail_transport(1, 500)?; + + let mut envelope = Envelope { + mail_from: "sender@here".to_string(), + rcpt_to: vec!["a1@localhost".to_string(), "b1@[127.0.0.1]".to_string()], + data: "message\r\n".as_bytes().to_vec(), + }; + + let (record_postfix_1, record_filtermail_1) = { + // simulate full queue on [127.0.0.1] worker + let _permit = transport + .workers + .get_permit(&AddressDomain::Literal("127.0.0.1".to_string())); + + let record_postfix = lmtp_send(&envelope).await?; + + let record_filtermail = remote_mta.recv().await.unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + assert!(remote_mta.is_empty()); + (record_postfix, record_filtermail) + }; + + // retry deferred + envelope.rcpt_to.remove(0); + let record_postfix_2 = lmtp_send(&envelope).await?; + let record_filtermail_2 = remote_mta.recv().await.unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + assert!(remote_mta.is_empty()); + + insta::assert_snapshot!(format!( + "TRANSACTION 1\r\n\ + [postfix -> filtermail-transport]\r\n{record_postfix_1}\r\n\ + [filtermail-transport -> destination A]\r\n{record_filtermail_1}\r\n\r\n\ + TRANSACTION 2\r\n\ + [postfix -> filtermail-transport]\r\n{record_postfix_2}\r\n\ + [filtermail-transport -> destination B]\r\n{record_filtermail_2}", + )); + + Ok(()) + } + + #[rstest] + #[serial] + #[tokio::test] + async fn test_smtp_send_mail_worker_cap_exceeded() -> TestResult { + let mut remote_mta = spawn_mock_mta()?; + spawn_filtermail_transport(30, 1)?; + + let envelope_1 = Envelope { + mail_from: "sender@here".to_string(), + rcpt_to: vec!["a1@localhost".to_string()], + data: "message\r\n".as_bytes().to_vec(), + }; + + let envelope_2 = Envelope { + mail_from: "sender@here".to_string(), + rcpt_to: vec!["b1@[127.0.0.1]".to_string()], + data: "message\r\n".as_bytes().to_vec(), + }; + + // 1 + let (record_postfix_1, record_filtermail_1) = { + let record_postfix = lmtp_send(&envelope_1).await?; + let record_filtermail = remote_mta.recv().await.unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + assert!(remote_mta.is_empty()); + (record_postfix, record_filtermail) + }; + + // 2 + let record_postfix_2 = lmtp_send(&envelope_2).await?; + tokio::time::sleep(Duration::from_secs(1)).await; + assert!(remote_mta.is_empty()); + + insta::assert_snapshot!(format!( + "TRANSACTION 1\r\n\ + [postfix -> filtermail-transport]\r\n{record_postfix_1}\r\n\ + [filtermail-transport -> destination A]\r\n{record_filtermail_1}\r\n\r\n\ + TRANSACTION 2\r\n\ + [postfix -> filtermail-transport]\r\n{record_postfix_2}" + )); + + Ok(()) + } +} diff --git a/filtermail/src/transport/https_client.rs b/filtermail/src/transport/https_client.rs new file mode 100644 index 00000000..eb6e10be --- /dev/null +++ b/filtermail/src/transport/https_client.rs @@ -0,0 +1,57 @@ +use crate::tls; +use hyper::body::Bytes; +use hyper_rustls::HttpsConnector; +use hyper_util::client::legacy::connect::HttpConnector; +use std::sync::Arc; +use tokio_rustls::rustls; + +/// Cheaply clonable HTTPS client. +/// +/// Holds regular secure variant and relaxed - without certificate verification. +/// +/// Connection pool handled internally by [`hyper_util::client::legacy::Client`]. +#[derive(Clone)] +pub(crate) struct HttpsClient { + pub secure: hyper_util::client::legacy::Client< + HttpsConnector, + http_body_util::Full, + >, + pub relaxed: hyper_util::client::legacy::Client< + HttpsConnector, + http_body_util::Full, + >, +} + +impl HttpsClient { + /// Creates a new `[HttpsClient]`. + pub fn new( + tls_resumption_store: Arc, + ) -> Result { + let tls_client_config = tls::configure_rustls(tls_resumption_store.clone(), false)?; + let https_connector = hyper_rustls::HttpsConnectorBuilder::new() + .with_tls_config(tls_client_config) + .https_only() + .enable_http1() + .enable_http2() + .build(); + let https_client = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build(https_connector); + + let tls_client_config_relaxed = tls::configure_rustls(tls_resumption_store, true)?; + let https_connector_relaxed = hyper_rustls::HttpsConnectorBuilder::new() + .with_tls_config(tls_client_config_relaxed) + .https_only() + .enable_http1() + .enable_http2() + .build(); + let https_client_relaxed = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build(https_connector_relaxed); + + Ok(Self { + secure: https_client, + relaxed: https_client_relaxed, + }) + } +} diff --git a/filtermail/src/transport/worker.rs b/filtermail/src/transport/worker.rs new file mode 100644 index 00000000..36c670e7 --- /dev/null +++ b/filtermail/src/transport/worker.rs @@ -0,0 +1,515 @@ +use crate::config::Config; +use crate::smtp_client::{SmtpConnectionPool, TlsConfig}; +use crate::smtp_responses::{OK_HTTPS_250, OK_SMTP_250}; +use crate::smtp_server::Envelope; +use crate::tcp::{TcpConnect, TcpStreamTrait}; +use crate::transport::{HEADER_MAIL_FROM, HEADER_RCPT_TO, https_client::HttpsClient}; +use crate::utils::{AddressDomain, build_resolver}; +use hickory_resolver::TokioResolver; +use hickory_resolver::proto::rr::RData; +use http_body_util::BodyExt; +use hyper::body::Bytes; +use parking_lot::RwLock; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc::OwnedPermit; +use tokio::sync::{mpsc, oneshot}; +use tokio::task; +use tokio::task::JoinHandle; +use tokio::time::timeout; +use tokio_rustls::rustls; + +#[cfg(not(test))] +const SMTP_PORT: u16 = 25; +#[cfg(not(test))] +const SMTP_SKIP_TLS: bool = false; + +#[cfg(test)] +const SMTP_PORT: u16 = 10025; +#[cfg(test)] +const SMTP_SKIP_TLS: bool = true; + +/// Message queue size per [`Worker`]. +/// +/// If a queue to a single destination reaches this limit, +/// all new messages will be immediately deferred. +const PER_DESTINATION_QUEUE_SIZE: usize = 30; + +/// Max number of [`Worker`]s operating at the same time. +const MAX_WORKERS: usize = 500; + +/// How long a worker can stay idle. +/// +/// Exceeding this value will cause a worker shutdown. +const WORKER_KEEPALIVE_DURATION: Duration = Duration::from_secs(60); + +type SMTPResponse = Result; + +pub struct WorkerPool { + inner: Arc>>>, + client_hostname: String, + smtp_connection_pool: Arc>, + mxdeliv_unsupported_hosts: Arc>, + monitor_handle: JoinHandle<()>, + dns_resolver: Arc, + queue_size: usize, + capacity: usize, +} + +impl WorkerPool +where + S: TcpStreamTrait + TcpConnect, + S::ConnectionContext: Default, +{ + pub fn new(config: Config) -> Result { + let dns_resolver = Arc::new(build_resolver()?); + + let mxdeliv_cache = Arc::new(retainer::Cache::new()); + let mxdeliv_cache_clone = mxdeliv_cache.clone(); + + let monitor_handle = tokio::spawn(async move { + mxdeliv_cache_clone + .monitor(4, 0.25, Duration::from_secs(10)) + .await + }); + + Ok(Self { + inner: Default::default(), + client_hostname: config.mail_domain, + dns_resolver, + smtp_connection_pool: SmtpConnectionPool::::new(Default::default()), + mxdeliv_unsupported_hosts: mxdeliv_cache, + monitor_handle, + queue_size: PER_DESTINATION_QUEUE_SIZE, + capacity: MAX_WORKERS, + }) + } + + /// Same as [`Self::new`], but lets you set the size of the queue and pool capacity. + /// + /// Used only for tests. + #[cfg(test)] + pub fn with_queue_size_and_pool_capacity( + config: Config, + queue_size: usize, + pool_capacity: usize, + ) -> Result { + let mut this = Self::new(config)?; + this.queue_size = queue_size; + this.capacity = pool_capacity; + Ok(this) + } + + /// Gets a worker for provided `destination`, + /// spawning a new one if required. + /// + /// Returns [`None`], if operation requires spawning a new worker, + /// but pool already operates at maximum worker capacity. + fn get_or_create_worker(&self, destination: &AddressDomain) -> Option> { + // NOTE: these locks are blocking, but critical section here is quite small and + // shouldn't cause issues in async code. + // NOTE: read() returns a guard that is dropped before the match statement. + // This must be ensured or else, the write() line would cause a deadlock. + let worker = { + let map = self.inner.read(); + map.get(destination).cloned() + }; + + match worker { + Some(worker) => Some(worker), + None => { + // Worker for this destination wasn't spawned yet. + let mut map = self.inner.write(); + + // we check if it wasn't spawned in the meantime first + if let Some(worker) = map.get(destination) + && !worker.handle.is_finished() + { + return Some(worker.clone()); + } + + if map.len() >= self.capacity { + log::warn!( + "Worker pool operating at maximum capacity! \ + Messages to new destinations will be deferred." + ); + return None; + } + + let (tx, rx) = mpsc::channel(self.queue_size); + let handle = tokio::spawn(Worker::run( + destination.clone(), + rx, + self.client_hostname.clone(), + self.smtp_connection_pool.clone(), + self.mxdeliv_unsupported_hosts.clone(), + self.dns_resolver.clone(), + self.inner.clone(), + )); + log::trace!("Worker {} spawned", handle.id()); + let worker = Arc::new(Worker { tx, handle }); + + map.insert(destination.clone(), worker.clone()); + Some(worker) + } + } + } + + /// Tries to get an [`OwnedPermit`] to the worker for specified destination. + /// + /// Returns [`None`] if: + /// + /// - the worker's queue is full, + /// - operation requires spawning a new worker, + /// but pool already operates at maximum worker capacity. + pub fn get_permit(&self, destination: &AddressDomain) -> Option> { + if let Some(worker) = self.get_or_create_worker(destination) { + return worker.tx.clone().try_reserve_owned().ok(); + } + None + } + + /// Informs pool that a worker has exited unexpectedly. + /// + /// Such worker will be removed from the pool, + /// and re-created on the next [`Self::get_permit`] call. + pub fn cleanup(&self, destination: &AddressDomain) { + let mut map = self.inner.write(); + // we first check in case other task already removed/re-created it + if let Some(worker) = map.get(destination) + && worker.handle.is_finished() + { + log::info!( + "Removing a dead worker {} for destination {destination}", + worker.handle.id() + ); + map.remove(destination); + } + } +} + +impl Drop for WorkerPool { + fn drop(&mut self) { + self.monitor_handle.abort(); + } +} + +#[derive(Debug)] +pub struct Worker { + pub tx: mpsc::Sender, + handle: JoinHandle>, +} + +impl Drop for Worker { + fn drop(&mut self) { + self.handle.abort(); + } +} + +impl Worker { + pub async fn run( + destination: AddressDomain, + mut rx: mpsc::Receiver, + client_hostname: String, + smtp_connection_pool: Arc>, + mxdeliv_unsupported_hosts: Arc>, + dns_resolver: Arc, + worker_pool: Arc>>>, + ) -> Result<(), crate::error::Error> + where + S: TcpStreamTrait + TcpConnect, + { + let worker_id = task::try_id() + .map(|id| id.to_string()) + .unwrap_or("?".to_string()); + + log::info!("Starting worker {worker_id} for destination {destination}"); + + let tls_resumption_store = Arc::new(rustls::client::ClientSessionMemoryCache::new(256)); + let https_client = HttpsClient::new(tls_resumption_store.clone())?; + + while let Ok(Some(message)) = timeout(WORKER_KEEPALIVE_DURATION, rx.recv()).await { + log::trace!( + "Worker {worker_id} received a message from {}", + message.envelope.mail_from + ); + let result = Self::handle_single_domain( + tls_resumption_store.clone(), + smtp_connection_pool.clone(), + mxdeliv_unsupported_hosts.clone(), + https_client.clone(), + dns_resolver.clone(), + destination.clone(), + message.envelope, + client_hostname.clone(), + ) + .await; + if message.response_tx.send(result).is_err() { + log::error!( + "Worker {worker_id} ({destination}) failed to send response to transport handler." + ); + }; + } + + log::info!("Worker {worker_id} for domain {destination} shutting down..."); + worker_pool.write().remove(&destination); + + Ok(()) + } + + /// Handles a single email transaction for a single recipient domain. + #[expect(clippy::too_many_arguments)] + async fn handle_single_domain( + tls_resumption_store: Arc, + smtp_connection_pool: Arc>, + mxdeliv_unsupported_hosts: Arc>, + https_client: HttpsClient, + dns_resolver: Arc, + domain: AddressDomain, + envelope: Envelope, + client_hostname: String, + ) -> Result + where + S: TcpStreamTrait + TcpConnect, + { + let mut allow_invalid_cert = false; + let mut skip_tls = SMTP_SKIP_TLS; // only respected by smtp channel + + let mx_hosts = match domain { + // no-DNS setup; assume the ip from email address is the destination. + AddressDomain::Literal(ip) => { + // We allow self-signed certs on IP-based relays. + allow_invalid_cert = true; + vec![(0, ip)] + } + AddressDomain::Name(mx_domain) => { + if mx_domain.eq_ignore_ascii_case("nauta.cu") { + // Special case; We don't want to defederate nauta.cu, + // which doesn't support STARTTLS at all. + skip_tls = true; + } else if mx_domain.starts_with('_') { + // We use domains starting with `_` for test deployments. + // (You can't request a non-wildcard cert for such domain) + allow_invalid_cert = true; + } + let query = format!("{mx_domain}."); + + match dns_resolver.mx_lookup(query).await { + Ok(mx_records) => { + let mut hosts: Vec<(u16, String)> = Vec::new(); + for mx_record in mx_records.answers() { + let mx = match mx_record.data { + RData::MX(ref mx) => mx, + _ => continue, + }; + + // Null MX / RFC7505 + if mx.exchange.is_root() { + // From RFC7505 section 3: + // > A domain that advertises a null MX MUST NOT + // > advertise any other MX RR. + // We assume this is the only record and exit early. + return Err( + "556 5.1.10 Permanent failure: Recipient address has null MX" + .to_string(), + ); + } + + let host = mx.exchange.to_string().trim_end_matches('.').to_string(); + hosts.push((mx.preference, host)) + } + hosts.sort(); + hosts + } + Err(e) => { + if e.is_no_records_found() { + // "implicit MX" as described by section 5.1 of RFC5321 + // https://datatracker.ietf.org/doc/html/rfc5321#section-5.1 + log::debug!("No MX record found, using implicit MX: {mx_domain}"); + vec![(0, mx_domain)] + } else if e.is_nx_domain() { + return Err(format!("512 Domain {mx_domain} does not exist")); + } else { + return Err(format!("421 DNS resolution failed for {mx_domain}")); + } + } + } + } + }; + + let tls_config = match skip_tls { + true => None, + false => Some(TlsConfig { + allow_invalid_cert, + session_cache: tls_resumption_store, + }), + }; + + let mut last_error = None; + + // we try sequentially in order of MX preference, + // but the IPv4 and IPv6 connections (after `smtp_client::send` resolves mx hostname) + // happens in parallel. + 'try_relay: for (_, mx_host) in mx_hosts { + let skip_mxdeliv = mxdeliv_unsupported_hosts + .get(&mx_host) + .await + .map(|guard| *guard.value()) + .is_some(); + + // HTTPS channel + if skip_mxdeliv { + log::debug!("Skipping HTTP delivery to host that failed recently: {mx_host}"); + } else { + match Self::https_delivery( + https_client.clone(), + mx_host.clone(), + &envelope, + allow_invalid_cert, + ) + .await + { + Ok(_) => { + return Ok(OK_HTTPS_250.to_string()); + } + Err(e) => { + log::debug!("HTTPS delivery to {mx_host} failed: {e}"); + } + } + } + + // SMTP channel (fallback) + let client_config = crate::smtp_client::ClientConfig { + client_hostname: &client_hostname, + tls_config: tls_config.clone(), + lmtp: false, + }; + match crate::smtp_client::send( + &mx_host, + SMTP_PORT, + &envelope, + client_config, + dns_resolver.clone(), + smtp_connection_pool.clone(), + ) + .await + { + Ok(_) => { + // Switches this host to SMTP for 30 minutes. + // Note: this MUST happen only after a successful SMTP delivery, + // or otherwise any http error will lock us out of any way to + // deliver to a relay with a blocked port 25 for 30 minutes. + mxdeliv_unsupported_hosts + .insert(mx_host.clone(), (), Duration::from_mins(30)) + .await; + return Ok(OK_SMTP_250.to_string()); + } + Err(error) => { + match &error { + // We only want to try other MX hosts if we encounter a problem + // related to connection. + // (So we don't spam other servers if the message is actually rejected.) + crate::error::Error::Io(_) + | crate::error::Error::ConnectionFailed(_) + | crate::error::Error::Tls(_) => { + // Make sure we quickly retry HTTP if SMTP failed to connect + mxdeliv_unsupported_hosts.remove(&mx_host).await; + log::warn!( + "Connection error relaying to mail server {mx_host}: {error}" + ); + last_error = Some((error.smtp_response(), mx_host.clone())); + continue 'try_relay; + } + crate::error::Error::MailSend { .. } => { + log::warn!("Message rejected by mail server {mx_host}: {error}"); + return Err(error.smtp_response()); + } + _ => { + log::warn!( + "Unexpected error while delivering to mail server {mx_host}: {error}" + ); + return Err(format!( + "{} (while attempting delivery to {mx_host})", + error.smtp_response() + )); + } + } + } + } + } + + let (error, mx_host) = last_error.unwrap_or(("?".to_string(), "?".to_string())); + Err(format!( + "421 Failed to connect to any mail server; last attempt to {mx_host}: {error}" + )) + } + + /// Performs mail delivery to `mx_host` over HTTPS. + /// + /// Times out after 60s. + async fn https_delivery( + https_client: HttpsClient, + mx_host: String, + envelope: &Envelope, + allow_invalid_cert: bool, + ) -> Result<(), crate::error::Error> { + let request: hyper::Request> = { + let mut builder = hyper::Request::builder() + .method(hyper::Method::POST) + .uri(format!("https://{mx_host}/mxdeliv")); + + if !envelope.mail_from.is_empty() { + builder = builder.header(HEADER_MAIL_FROM, &envelope.mail_from); + } + + for rcpt_to in &envelope.rcpt_to { + builder = builder.header(HEADER_RCPT_TO, rcpt_to); + } + + builder.body(http_body_util::Full::from(envelope.data.clone()))? + }; + + let client = if allow_invalid_cert { + https_client.relaxed + } else { + https_client.secure + }; + + let response = tokio::time::timeout(Duration::from_secs(60), client.request(request)) + .await + .map_err(|_| crate::error::Error::MailSend { + context: "HTTPS delivery".to_string(), + raw_smtp_answer: "[timeout]".to_string(), + host: mx_host.clone(), + })??; + if response.status().is_success() { + Ok(()) + } else { + let response_body = response.collect().await?.to_bytes(); + Err(crate::error::Error::MailSend { + context: "HTTPS delivery".to_string(), + raw_smtp_answer: String::from_utf8_lossy(&response_body).into(), + host: mx_host, + }) + } + } +} + +pub struct WorkerMessage { + pub envelope: Envelope, + pub response_tx: oneshot::Sender, +} + +impl WorkerMessage { + pub fn new(envelope: Envelope) -> (Self, oneshot::Receiver) { + let (response_tx, response_rx) = oneshot::channel(); + ( + Self { + envelope, + response_tx, + }, + response_rx, + ) + } +} diff --git a/filtermail/src/utils.rs b/filtermail/src/utils.rs new file mode 100644 index 00000000..2139cabe --- /dev/null +++ b/filtermail/src/utils.rs @@ -0,0 +1,198 @@ +use hickory_resolver::{TokioResolver, proto::dnssec::TrustAnchors}; +use mailparse::MailAddr; +use std::fmt::{Display, Formatter}; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +/// Extracts the first email address found in SMTP command or email header. +/// +/// Return `None` if parsing fails. +/// +/// Returns the first address if multiple are present. +pub fn extract_address(input: &str) -> Option { + let input_lower = input.to_lowercase(); + let mut trimmed = input_lower + .trim_start_matches("mail from:") + .trim_start_matches("rcpt to:"); + + let addr_end = trimmed.find('>').unwrap_or(trimmed.len() - 1); + trimmed = trimmed + .split_at_checked(addr_end + 1) + .map(|(address_raw, _)| address_raw) + .unwrap_or(trimmed); + + mailparse::addrparse(trimmed) + .ok() + .and_then(|addr| match addr.first() { + Some(MailAddr::Single(single)) => Some(single.addr.clone()), + Some(MailAddr::Group(group)) => group.addrs.first().map(|single| single.addr.clone()), + None => None, + }) +} + +/// Domain part of an email address, either a domain-literal (IP address in square brackets with +/// optional protocol prefix) or a regular domain name. +#[derive(Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord)] +pub enum AddressDomain { + /// Domain literal, e.g. + /// - `192.0.2.0` in `test@[192.0.2.0]`, + /// - `2001:db8::1` in `test@[IPv6:2001:db8::1]`. + Literal(String), + /// Regular domain name, e.g. `example.org` in `test@example.org`. + Name(String), +} + +impl FromStr for AddressDomain { + type Err = crate::error::Error; + + /// Extracts the domain part from an email address and returns it as an [`AddressDomain`]. + /// + /// Returns an [`Error`] if `address` is not a valid email address. + /// + /// [`Error`]: crate::error::Error + fn from_str(address: &str) -> Result { + let parts: Vec<&str> = address.split('@').collect(); + if parts.len() == 2 + && let Some(domain) = parts.get(1) + { + // domain literals + if domain.starts_with('[') && domain.ends_with(']') { + let mut address_trimmed = domain + .get(1..domain.len() - 1) + .unwrap_or(domain) + .to_lowercase(); + + address_trimmed = address_trimmed + .strip_prefix("ipv6:") + .unwrap_or(&address_trimmed) + .to_string(); + + return Ok(AddressDomain::Literal(address_trimmed.to_string())); + } + Ok(AddressDomain::Name(domain.to_string())) + } else { + Err(crate::error::Error::InvalidEmailAddress( + address.to_string(), + )) + } + } +} + +impl AsRef for AddressDomain { + fn as_ref(&self) -> &str { + match self { + AddressDomain::Literal(literal) => literal.as_ref(), + AddressDomain::Name(name) => name.as_ref(), + } + } +} + +impl Display for AddressDomain { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + AddressDomain::Literal(addr) => { + // naive check, but should be enough as long as `AddressDomain` + // is constructed using parse/from_str. + write!(f, "[")?; + if addr.contains(':') { + write!(f, "IPv6:")?; + } + write!(f, "{addr}]")?; + } + AddressDomain::Name(domain) => write!(f, "{domain}")?, + }; + Ok(()) + } +} + +/// Logs email to `/tmp/filtermail-rejected//.eml` +/// and returns the file path. +/// +/// Returns [`crate::error::Error`] on IO error. +pub async fn log_eml(reason: &str, data: &[u8]) -> Result { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let filename = format!("{timestamp}.eml"); + let mut path = PathBuf::from(format!("/tmp/filtermail-rejected/{reason}")); + tokio::fs::create_dir_all(&path).await?; + path.push(filename); + tokio::fs::write(&path, data).await?; + Ok(path) +} + +/// Creates a DNS resolver with DNSSEC enabled and system configuration (resolv.conf). +pub fn build_resolver() -> Result { + let mut builder = TokioResolver::builder_tokio()? + // https://github.com/hickory-dns/hickory-dns/issues/3519 + .with_trust_anchor(Arc::new(TrustAnchors::default())); + + // disable negative caching to prevent possible federation problems + builder.options_mut().negative_max_ttl = Some(Duration::ZERO); + + let dns_resolver = builder.build()?; + + assert!( + dns_resolver.options().validate, + "incorrect resolver config: DNSSEC disabled; exiting" + ); + + Ok(dns_resolver) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::*; + + #[rstest] + #[case("MAIL FROM:", Some("t1@example.org".to_string()))] + #[case("MAIL FROM: SOMETHING=SOMETHING OTHER=OTHER", Some("t2@example.org".to_string()))] + #[case("MAIL FROM: abc=def", Some("srs1=hhh=example.com==hhh=tt=example.org=alice@example.net".to_string()))] + #[case("MAIL FROM: abc=def", Some("abc+alice@example.net".to_string()))] + #[case("RCPT TO:", Some("t3@example.org".to_string()))] + #[case("mail from:", Some("t4@example.org".to_string()))] + #[case("Foo Bar ", Some("t5@example.org".to_string()))] + #[case("t6@example.org", Some("t6@example.org".to_string()))] + #[case("t7@[192.0.2.0]", Some("t7@[192.0.2.0]".to_string()))] + #[case("", Some("t7@[192.0.2.0]".to_string()))] + // This is a bug in mailparse, it refuses to parse IPv6 without "<>" around. + // https://github.com/staktrace/mailparse/issues/137 + #[case("t8@[IPv6:2001:db8::1]", None)] + #[case("", Some("t8@[ipv6:2001:db8::1]".to_string()))] + fn test_extract_address(#[case] input: &str, #[case] expected: Option) { + let result = extract_address(input); + assert_eq!(result, expected) + } + + #[rstest] + #[case("t1@example.org", Some(AddressDomain::Name("example.org".to_string())))] + #[case("SRS1=HHH=example.com==HHH=TT=example.org=alice@example.net", Some(AddressDomain::Name("example.net".to_string())))] + #[case("t7@[192.0.2.0]", Some(AddressDomain::Literal("192.0.2.0".to_string())))] + #[case("t8@[IPv6:2001:db8::1]", Some(AddressDomain::Literal("2001:db8::1".to_string())))] + #[case("invalid", None)] + #[case("invalid@address@com", None)] + fn test_get_domain_from_address(#[case] input: &str, #[case] expected: Option) { + let result = AddressDomain::from_str(input).ok(); + assert_eq!(result, expected); + } + + #[rstest] + #[case::domain(AddressDomain::Name("example.org".to_string()), "example.org")] + #[case::ipv4(AddressDomain::Literal("192.0.2.0".to_string()), "192.0.2.0")] + #[case::ipv6(AddressDomain::Literal("2001:db8::1".to_string()), "2001:db8::1")] + fn test_address_domain_as_ref(#[case] input: AddressDomain, #[case] expected: &str) { + assert_eq!(input.as_ref(), expected); + } + + #[rstest] + #[case::domain(AddressDomain::Name("example.org".to_string()), "example.org")] + #[case::ipv4(AddressDomain::Literal("192.0.2.0".to_string()), "[192.0.2.0]")] + #[case::ipv6(AddressDomain::Literal("2001:db8::1".to_string()), "[IPv6:2001:db8::1]")] + fn test_address_domain_display(#[case] input: AddressDomain, #[case] expected: &str) { + assert_eq!(&format!("{input}"), expected); + } +} diff --git a/filtermail/test_data/asm.eml b/filtermail/test_data/asm.eml new file mode 100644 index 00000000..372d0041 --- /dev/null +++ b/filtermail/test_data/asm.eml @@ -0,0 +1,56 @@ +From: one@example.org +To: two@example.org +Autocrypt-Setup-Message: v1 +Subject: Autocrypt Setup Message +Date: Tue, 22 Jan 2019 12:56:29 +0100 +Content-type: multipart/mixed; boundary="Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ" + +--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ +Content-Type: text/plain + +This message contains all information to transfer your Autocrypt +settings along with your secret key securely from your original +device. + +To set up your new device for Autocrypt, please follow the +instuctions that should be presented by your new device. + +You can keep this message and use it as a backup for your secret +key. If you want to do this, you should write down the Setup Code +and store it securely. +--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ +Content-Type: application/autocrypt-setup +Content-Disposition: attachment; filename="autocrypt-setup-message.html" + + +

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

+ +
+-----BEGIN PGP MESSAGE-----
+Passphrase-Format: numeric9x4
+Passphrase-Begin: 17
+
+jA0EBwMCFAxADoCdzeX/0ukBlqI5+pfpKb751qd/7nLNbkpy3gVcaf1QwRPZYt40
+Ynp08UqRQ2g48ZlnzHLSwlTGOPTuv2Jt8ka+pgZ45xzvJSG2gau03xP4VsC271kR
+VmCjdb0Y6Rk96mAwfGzrkbaRQ9Z7fIoL866GOv6h9neiVIkp+JYlTV6ISD0ZQJ4Q
+I6dOQkB/TWZyVjtiJDOQHdfNWliA6NtqaLq19wlu9L5xXjuNpY95KwR8EJXWe0+o
+Y3d2U/KxOAkXKghP2Qg1GtlPVeGC5T4p03TGI6pzKT+kHX6Rrm9wK6sM9aTquMmF
+Vok84Jg1DFnwivWC2RILR81rXi7k/+Y6MUbveFgJ9cQduqpxnmD7TjOblYu7M6zp
+YGAUxh8DRKlIMn2QsA++DBYQ6ACZvwuY8qTDLkqPDo4WqM313dsMJbyGjDdVE7EM
+PESS+RlABETpZXz8g/ycr6DIUNdlbPcmYlsBfHWDOuR2GFFTwmlv5slWS39dJv38
+E0eIe1CwdxI801Se7t7dUUS/ZF8wb6GlmxOcqGbF8eko1Z0S64IAm7/h13MRQCxI
+geQnHfGYVJ2FOimoCMEKwfa9x++RFTDW0u7spDC2uWvK/1viV8OfRppFhLr/kmKb
+18lWXuAz80DAjUDUsVqEq2MvJBJGoCJUEyjuRsLkHYRM5jYk4v50LyyR0Om73nWF
+nZBqmqNzdr7Xb9PHHdFhnEc0VvoYbrcM0RVYcEMW3YbmejM891j1d6Iv+/n/qND/
+NdebGrfWJMmFLf/iEkzTZ3/v5inW9LpWoRc94ioCjJTaEo8Rib6ARRFaJVIsmNXi
+YicFGO98D+zX+a2t9Yz6IpPajVslnOp6ScpmXgts/2XWD7oE+JgxSAqo/dLVsHgP
+Ufo=
+=pulM
+-----END PGP MESSAGE-----
+
+--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ-- diff --git a/filtermail/test_data/dkim-abjadiyah.eml b/filtermail/test_data/dkim-abjadiyah.eml new file mode 100644 index 00000000..17c5e03e --- /dev/null +++ b/filtermail/test_data/dkim-abjadiyah.eml @@ -0,0 +1,47 @@ +DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=abjadiyah.xyz; + s=default; t=1771356412; + bh=EVfAHeUMDygbJe0SkMWJHjgXGjtiTLZnMQbyWqzsrCY=; + h=From:To:Subject:Date:From; + b=3GHd4aCp7sjNR7RfHzLUUQ0PLk4FM8WLlOO5d8BXvpa6oCjERP0vsP545EDo4lBFC + xg9dlSrhIwEWsrJDXSFStA7VU1WmWBVTzKoN8bZk5w/2HJf7BcB1BG2SQ0tyu2iEtl + sv21HCXLw1IAMd2IiZNPQRlk0PgrKDqFmgULbktIWkc917GNAWhJaVCQhk+YF6cfql + AKP3i8NPK53JY6vsMBO3YuYb/DcRGyvhUUJGofgG2+NwESA20ayI85EPiUzbzGpx0z + r5IsLwiMApO5W0svfzgU52VhtkAqVyi38ZvjYsCJlKXB885SRsHsojCCSIznvFSmNw + pWF4qKBvNrQpQ== +From: +To: "hidden-recipients": ; +Subject: [...] +Date: Sat, 14 Feb 2026 21:13:38 +0000 +X-Last-TLS-Session-Version: TLSv1.3 +X-Spamd-Result: default: False [5.18 / 15.00]; + MISSING_MID(2.50)[]; + FORGED_RECIPIENTS(2.00)[m:,s:link2xt@testrun.org]; + DATE_IN_PAST(1.00)[70]; + DMARC_POLICY_ALLOW(-0.50)[abjadiyah.xyz,reject]; + R_MISSING_CHARSET(0.50)[]; + R_DKIM_ALLOW(-0.20)[abjadiyah.xyz:s=default]; + R_SPF_ALLOW(-0.20)[+mx]; + MIME_GOOD(-0.10)[text/plain]; + FISHY_TLD(0.10)[abjadiyah.xyz]; + ONCE_RECEIVED(0.10)[]; + IP_REPUTATION_HAM(-0.01)[asn: 29670(0.00), country: DE(-0.01), ip: 2001:67c:1400:21d0::(0.00)]; + MX_GOOD(-0.01)[]; + MISSING_XM_UA(0.00)[]; + RCPT_COUNT_ONE(0.00)[1]; + BCC(0.00)[]; + ARC_NA(0.00)[]; + MIME_TRACE(0.00)[0:+]; + DWL_DNSWL_BLOCKED(0.00)[abjadiyah.xyz:dkim]; + DKIM_TRACE(0.00)[abjadiyah.xyz:+]; + RCPT_MAILCOW_DOMAIN(0.00)[testrun.org]; + SINGLE_SHORT_PART(0.00)[]; + ARC_SIGNED(0.00)[testrun.org:s=dkim:i=1]; + RCVD_COUNT_ZERO(0.00)[0]; + TO_DN_ALL(0.00)[]; + ASN(0.00)[asn:29670, ipnet:2001:67c:1400::/45, country:DE]; + FROM_NO_DN(0.00)[]; + FROM_EQ_ENVFROM(0.00)[] +X-Rspamd-Queue-Id: E471A4105C + +Hello! + diff --git a/filtermail/test_data/dkim-privitty.eml b/filtermail/test_data/dkim-privitty.eml new file mode 100644 index 00000000..c23274cd --- /dev/null +++ b/filtermail/test_data/dkim-privitty.eml @@ -0,0 +1,80 @@ +Received: from chat.privittytech.com (unknown [35.154.144.0]) + by nine.testrun.org (Postfix) with ESMTPS + for ; Sat, 21 Feb 2026 22:17:25 +0100 (CET) +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; + d=chat.privittytech.com; s=opendkim; t=1771708644; + bh=Qs0Xoe6dH8cM3t3hv45QSkQQe5TLvyJ5R4OGJL9GYUc=; + h=Content-Type:From:To:Subject:Date:References:from:reply-to: + subject:date:to:cc:resent-date:resent-from:resent-sender:resent-to: + resent-cc:in-reply-to:references:list-id:list-help: + list-unsubscribe:list-subscribe:list-post:list-owner:list-archive: + autocrypt; + b=rsdW71iU79CF0dooc1PnbZsJbWZsnBampamyF3jTWirF8AXBBKeR2JJsxr4km3Vx9 + QoT1s7VAyl/hdUBh2tBWT7Kzvw4KJcgkZ7tV/uRcY7edlLoH8TqATeTkWeNX18rL92 + F6Vv/6krs/z8Rpz2ZaVkBdLQif0fLQ3e96ODOdpgkLFXWsMVTM5rGepPWYht8yA+BF + /sMopzgHhJaBvDqV42Ep75IK4w3opf9fPTAvtpwKbnDmJQmLSDv1So/Rd4xL3Oj6PX + CSO8dfJSzQ+ukyoDqVMddVCUpgVgFHTXMdxxQVTe54tj2JPZan5ql90SHa3lNFTgN8 + hrM8BExznme5Q== +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="18965f9fc8f94f5b_d3ca8c34f6c5d05_4df177c93ae6931" +MIME-Version: 1.0 +From: <8dn75miab@chat.privittytech.com> +To: "hidden-recipients": ; +Subject: [...] +Date: Mon, 16 Feb 2026 06:28:47 +0000 +Message-ID: <0737aaa0-d6f9-4240-9d94-1b73216ca96d@localhost> +References: <0737aaa0-d6f9-4240-9d94-1b73216ca96d@localhost> +Chat-Version: 1.0 + + +--18965f9fc8f94f5b_d3ca8c34f6c5d05_4df177c93ae6931 +Content-Type: application/pgp-encrypted; charset="utf-8" +Content-Description: PGP/MIME version identification +Content-Transfer-Encoding: 7bit + +Version: 1 + +--18965f9fc8f94f5b_d3ca8c34f6c5d05_4df177c93ae6931 +Content-Type: application/octet-stream; name="encrypted.asc"; + charset="utf-8" +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc"; +Content-Transfer-Encoding: 7bit + +-----BEGIN PGP MESSAGE----- + +wU4DWvqoogTPq7gSAQdAO8msB6rJLzr5isiWx/ZoAHqNnYZTcE4fpjpj8C1+Vhgg +c3o2P2p9T2GfJ6aa6KqbJQW7VPl8zaaylYCN8JQLKqHBTgPP/P3FoTw8jxIBB0CE +/iffxyAThaUNNQyp0vmrW9k8v47ll3TBQjCv6K/bbiB7u1vsYbBfl8FQgjDNhHTV +U+lJQntsqmsn3VmxRjz1vdLD4QGvqJXYHUUyYJ7EetMBHFcmpCLlO14MhvZKw4Gd +yWhEcgv6fsFpBaTk7Y9MXrflbpSR84aCtNqEKWU6FjjFWoRUzWVV42gI0cXectg6 +Ry2p21EfiBsLLaQdMneep7Hpq/GXccLZcFVVc9VCQq80lq/9GkiDSdA/9JD3gGFu +NCDMKkgAQRaKVkfGZ7VhQqpkAZeeI3H/kGjzFElcNUqTH1lRgi7OgJuhIA9VeIWb +ZX677i3tjnNWrAJoIVs+l5I7jWhgE9VMutI4kBA0fxk2QYW++n9TPz2/FbTuFkjZ +ULXQC8F7CaVdUVb1DIAc7hw8EGFX98LKj04ucBuoe1QfJK6LKbhelSmGYGQbBOOG +AZSPs/jgH1EkgtIzxD6rTJTFy/+2Y4eOTJaq0F4ZP32+pOVr3Iq2OuSS3LakfCjk +TPqe9Y1w4q0afQdS2tHlnLGOGrFxoYeRjUnTeeHuH9sSp6G6Lf5751sLVgYUtILo +Kgaac+gCh0rBK4Ve4LksnY6FUzogLYxIt4NkmDIWDvlDc0RnC+moQ/VaICPYlnMl +dJB4mVFKefqqQM61rWTZyqHc8tDJj0D1dqpxkYJT+VH+t0RNp6br8I+wQ91BP595 +Usma3Uv1MjLufMFWcTtd72PAfk13uWF2u2dkKO+KR4MUnXm/B3ZSBAUfGfBaH4SP +mn/dDeNY0c9ya+8l+Nt2lK/3imh3E29z22pnVwTB/PAK1HCxrehw6j/W6cfrylXC +TcqLJW2K542oy5Q6zE5sU6mYRfOQcoR/s3t7y5ynu3s29kIbUhysPN2qT7YMmTWo +hSlMHU5oDrzDNKWkE1FsXpTCGTdSvKhQf/HuzmQBXiCQxLlH+55UJPwbf2H95o1A +cVChQP6vwqBIrB0k1CkJbfqCMPvr7fyNcZNqdk+iyXwPu9vOhE+4k5ybCZ7t/uoY +DHm05/nj5jqIEG2vV0sTogd8dnu6KiVK+wET4mZwOMmAPp/8ROOmZ2NvEK1iPlgL +c92u2XLAgZW+AbI93oJARWI8MGT5CfvJcKvjO1Nr4KIjN2G4uMiE//f2hPM676mq +eeFqFgITGN5ivfwnfdRgEHB63mkNeTVpzrHLNYYH/HVPdjsTqDp0cPCvZxoIDgTj +/9+uJtfgRekpD0LqOfnUg27TBthFksuFaiMW4cvxgNdqOVbo7RjcSvGg2pd80Bu0 +0gv/47tDGl0Hn9BI+XIbwLRos6dSzxgU6XotjfA31lcSFiP99IkWH4sBFRWVIaEX +iPqJOmDWsgWjzzgu9JYQ8cZH4BP74Rv8qCC2klP4HfdQuErekNu7vDPLH3iHWpuX +HcSc6imuNXz45ncwyEsQ+tP4t42SEv0dGRaMpwNIniIiKYAL+dMFNvdqEsKiPRoG +f7xEuY2sZKoBtNw+gXcGlYs+QhSXJtpAdpHg+Bp0AYUQBU9ciYFfk8ZsEbSdpWGP +D0+Xd7bWrgUYN0UM0EdtpbyxnH/bFe3Jg5jdDuS43ifhYTEPEdpmG5pnqNk5Yi0Q +wxN6ntEHbfy3gmhMePOigh09RlwgbdoqurlvlUL2FHEY/Z1P9VI0tHbTXayUcBQk +3aljbg== +=wRZT +-----END PGP MESSAGE----- + + +--18965f9fc8f94f5b_d3ca8c34f6c5d05_4df177c93ae6931-- + diff --git a/filtermail/test_data/encrypted-ipv4.eml b/filtermail/test_data/encrypted-ipv4.eml new file mode 100644 index 00000000..0d32e3ac --- /dev/null +++ b/filtermail/test_data/encrypted-ipv4.eml @@ -0,0 +1,66 @@ +From: one@[192.0.2.0] +To: two@example.org +Subject: ... +Date: Sun, 15 Oct 2023 16:43:21 +0000 +Message-ID: +In-Reply-To: +References: + +Chat-Version: 1.0 +Autocrypt: addr=one@example.org; prefer-encrypt=mutual; + keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH + rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID + FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW + XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK + KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ + JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP + IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO +MIME-Version: 1.0 +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="YFrteb74qSXmggbOxZL9dRnhymywAi" + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: PGP/MIME version identification +Content-Type: application/pgp-encrypted + +Version: 1 + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc"; +Content-Type: application/octet-stream; name="encrypted.asc" + +-----BEGIN PGP MESSAGE----- + +wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg +O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae +8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI +JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no +lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz +ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM +YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA +kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI ++lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg +RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo +tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7 +rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp +H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI +fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9 +61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN +XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3 +w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb +NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs +baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW +A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8 +uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI +E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn +lkOWnEbCD+XTnbDd +=agR5 +-----END PGP MESSAGE----- + + +--YFrteb74qSXmggbOxZL9dRnhymywAi-- + + diff --git a/filtermail/test_data/encrypted-ipv6.eml b/filtermail/test_data/encrypted-ipv6.eml new file mode 100644 index 00000000..e728ce97 --- /dev/null +++ b/filtermail/test_data/encrypted-ipv6.eml @@ -0,0 +1,66 @@ +From: one@[IPv6:2001:db8::1] +To: two@example.org +Subject: ... +Date: Sun, 15 Oct 2023 16:43:21 +0000 +Message-ID: +In-Reply-To: +References: + +Chat-Version: 1.0 +Autocrypt: addr=one@example.org; prefer-encrypt=mutual; + keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH + rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID + FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW + XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK + KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ + JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP + IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO +MIME-Version: 1.0 +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="YFrteb74qSXmggbOxZL9dRnhymywAi" + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: PGP/MIME version identification +Content-Type: application/pgp-encrypted + +Version: 1 + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc"; +Content-Type: application/octet-stream; name="encrypted.asc" + +-----BEGIN PGP MESSAGE----- + +wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg +O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae +8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI +JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no +lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz +ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM +YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA +kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI ++lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg +RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo +tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7 +rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp +H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI +fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9 +61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN +XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3 +w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb +NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs +baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW +A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8 +uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI +E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn +lkOWnEbCD+XTnbDd +=agR5 +-----END PGP MESSAGE----- + + +--YFrteb74qSXmggbOxZL9dRnhymywAi-- + + diff --git a/filtermail/test_data/encrypted.eml b/filtermail/test_data/encrypted.eml new file mode 100644 index 00000000..889c1cde --- /dev/null +++ b/filtermail/test_data/encrypted.eml @@ -0,0 +1,66 @@ +From: one@example.org +To: two@example.org +Subject: {subject} +Date: Sun, 15 Oct 2023 16:43:21 +0000 +Message-ID: +In-Reply-To: +References: + +Chat-Version: 1.0 +Autocrypt: addr=one@example.org; prefer-encrypt=mutual; + keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH + rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID + FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW + XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK + KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ + JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP + IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO +MIME-Version: 1.0 +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="YFrteb74qSXmggbOxZL9dRnhymywAi" + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: PGP/MIME version identification +Content-Type: application/pgp-encrypted + +Version: 1 + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc"; +Content-Type: application/octet-stream; name="encrypted.asc" + +-----BEGIN PGP MESSAGE----- + +wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg +O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae +8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI +JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no +lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz +ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM +YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA +kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI ++lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg +RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo +tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7 +rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp +H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI +fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9 +61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN +XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3 +w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb +NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs +baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW +A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8 +uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI +E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn +lkOWnEbCD+XTnbDd +=agR5 +-----END PGP MESSAGE----- + + +--YFrteb74qSXmggbOxZL9dRnhymywAi-- + + diff --git a/filtermail/test_data/fake-encrypted.eml b/filtermail/test_data/fake-encrypted.eml new file mode 100644 index 00000000..b612509e --- /dev/null +++ b/filtermail/test_data/fake-encrypted.eml @@ -0,0 +1,25 @@ +Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?= +Chat-Disposition-Notification-To: foobar@c2.testrun.org +Chat-User-Avatar: 0 +From: +To: +Date: Sun, 15 Oct 2023 16:41:44 +0000 +Message-ID: +References: +Chat-Version: 1.0 +Autocrypt: addr=one@example.org; prefer-encrypt=mutual; + keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m + nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID + FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I + HjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK + KwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ + JlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI + vYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no + +-----BEGIN PGP MESSAGE----- +Meow! +-----END PGP MESSAGE----- + + diff --git a/filtermail/test_data/literal.eml b/filtermail/test_data/literal.eml new file mode 100644 index 00000000..817e9c84 --- /dev/null +++ b/filtermail/test_data/literal.eml @@ -0,0 +1,44 @@ +From: one@example.org +To: two@example.org +Subject: ... +Date: Sun, 15 Oct 2023 16:43:21 +0000 +Message-ID: +In-Reply-To: +References: + +Chat-Version: 1.0 +Autocrypt: addr=one@example.org; prefer-encrypt=mutual; + keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH + rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID + FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW + XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK + KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ + JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP + IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO +MIME-Version: 1.0 +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="YFrteb74qSXmggbOxZL9dRnhymywAi" + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: PGP/MIME version identification +Content-Type: application/pgp-encrypted + +Version: 1 + + +--YFrteb74qSXmggbOxZL9dRnhymywAi +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc"; +Content-Type: application/octet-stream; name="encrypted.asc" + +-----BEGIN PGP MESSAGE----- + +yxJiAAAAAABIZWxsbyB3b3JsZCE= +=1I/B +-----END PGP MESSAGE----- + + +--YFrteb74qSXmggbOxZL9dRnhymywAi-- + + diff --git a/filtermail/test_data/mailer-daemon.eml b/filtermail/test_data/mailer-daemon.eml new file mode 100644 index 00000000..66e7c7c2 --- /dev/null +++ b/filtermail/test_data/mailer-daemon.eml @@ -0,0 +1,46 @@ +Date: Fri, 8 Jul 1994 09:21:47 -0400 +From: Mail Delivery Subsystem +Subject: Returned mail: User unknown +To: +Auto-Submitted: auto-replied +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=delivery-status; + boundary="JAA13167.773673707/CS.UTK.EDU" + +--JAA13167.773673707/CS.UTK.EDU +content-type: text/plain; charset=us-ascii + + ----- The following addresses had delivery problems ----- + (unrecoverable error) + (unrecoverable error) + +--JAA13167.773673707/CS.UTK.EDU +content-type: message/delivery-status + +Reporting-MTA: dns; cs.utk.edu + +Original-Recipient: rfc822;arathib@vnet.ibm.com +Final-Recipient: rfc822;arathib@vnet.ibm.com +Action: failed +Status: 5.0.0 (permanent failure) +Diagnostic-Code: smtp; + 550 'arathib@vnet.IBM.COM' is not a registered gateway user +Remote-MTA: dns; vnet.ibm.com + +Original-Recipient: rfc822;johnh@hpnjld.njd.hp.com +Final-Recipient: rfc822;johnh@hpnjld.njd.hp.com +Action: delayed +Status: 4.0.0 (hpnjld.njd.jp.com: host name lookup failure) + +Original-Recipient: rfc822;wsnell@sdcc13.ucsd.edu +Final-Recipient: rfc822;wsnell@sdcc13.ucsd.edu +Action: failed +Status: 5.0.0 +Diagnostic-Code: smtp; 550 user unknown +Remote-MTA: dns; sdcc13.ucsd.edu + +--JAA13167.773673707/CS.UTK.EDU +content-type: message/rfc822 + +[original message goes here] +--JAA13167.773673707/CS.UTK.EDU-- diff --git a/filtermail/test_data/mdn.eml b/filtermail/test_data/mdn.eml new file mode 100644 index 00000000..16fbdf38 --- /dev/null +++ b/filtermail/test_data/mdn.eml @@ -0,0 +1,33 @@ +Subject: Message opened +From: +To: +Date: Sun, 15 Oct 2023 16:43:25 +0000 +Message-ID: +Auto-Submitted: auto-replied +Chat-Version: 1.0 +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=disposition-notification; + boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi" + + +--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi +Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no + +The "Hi!" message you sent was displayed on the screen of the recipient. + +This is no guarantee the content was read. + + +--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi +Content-Type: message/disposition-notification + +Reporting-UA: Delta Chat 1.124.1 +Original-Recipient: rfc822;barbaz@c2.testrun.org +Final-Recipient: rfc822;barbaz@c2.testrun.org +Original-Message-ID: +Disposition: manual-action/MDN-sent-automatically; displayed + + +--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi-- + + diff --git a/filtermail/test_data/plain.eml b/filtermail/test_data/plain.eml new file mode 100644 index 00000000..6c44f1ed --- /dev/null +++ b/filtermail/test_data/plain.eml @@ -0,0 +1,23 @@ +Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?= +Chat-Disposition-Notification-To: foobar@c2.testrun.org +Chat-User-Avatar: 0 +From: +To: +Date: Sun, 15 Oct 2023 16:41:44 +0000 +Message-ID: +References: +Chat-Version: 1.0 +Autocrypt: addr=one@example.org; prefer-encrypt=mutual; + keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m + nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID + FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I + HjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK + KwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ + JlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI + vYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no + +Meow! + + diff --git a/filtermail/test_data/securejoin-vc-fake.eml b/filtermail/test_data/securejoin-vc-fake.eml new file mode 100644 index 00000000..8051009e --- /dev/null +++ b/filtermail/test_data/securejoin-vc-fake.eml @@ -0,0 +1,21 @@ +Subject: Message from one@example.org +From: +To: +Date: Sun, 15 Oct 2023 16:43:25 +0000 +Message-ID: +Chat-Version: 1.0 +Secure-Join: vc-request +Secure-Join-Invitenumber: RANDOM-TOKEN +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi" + + +--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi +Content-Type: text/plain; charset=utf-8 + +Meow! + + +--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi-- + + diff --git a/filtermail/test_data/securejoin-vc.eml b/filtermail/test_data/securejoin-vc.eml new file mode 100644 index 00000000..9503a8a5 --- /dev/null +++ b/filtermail/test_data/securejoin-vc.eml @@ -0,0 +1,21 @@ +Subject: Message from one@example.org +From: +To: +Date: Sun, 15 Oct 2023 16:43:25 +0000 +Message-ID: +Chat-Version: 1.0 +Secure-Join: vc-request +Secure-Join-Invitenumber: RANDOM-TOKEN +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi" + + +--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi +Content-Type: text/plain; charset=utf-8 + +Secure-Join: vc-request + + +--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi-- + +