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