mirror of
https://github.com/chatmail/relay.git
synced 2026-08-10 10:30:51 +00:00
Merging chatmail/filtermail repository into chatmail/relay
This commit is contained in:
Vendored
+15
@@ -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
|
||||||
Vendored
+107
@@ -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
|
||||||
+67
@@ -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/*
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
@@ -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
|
||||||
Generated
+3109
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
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.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -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 <config> (incoming|outgoing|transport)
|
||||||
|
```
|
||||||
|
where `<config>` 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/<mail_domain>`.
|
||||||
|
|
||||||
|
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).
|
||||||
@@ -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 <REPO> with a URL.
|
||||||
|
#{ pattern = '<REPO>', 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}](<REPO>/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
|
||||||
@@ -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"]++
|
||||||
|
}
|
||||||
@@ -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<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Path>) -> Result<Self, crate::error::Error> {
|
||||||
|
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/<mail_domain>` 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TokioResolver>,
|
||||||
|
// Note: Arc is required despite we are holding the whole handler in an Arc,
|
||||||
|
// because viadkim will internally clone the resolver (LookupTxt + Clone + 'static)
|
||||||
|
// to parallelize lookups in case of multiple signatures...
|
||||||
|
cache: Arc<parking_lot::Mutex<LruCache<Name, Vec<Vec<u8>>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CachedResolver {
|
||||||
|
/// Creates a new [`CachedResolver`].
|
||||||
|
pub fn new(dns_resolver: Arc<TokioResolver>) -> 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<dyn Iterator<Item = io::Result<Vec<u8>>>>;
|
||||||
|
type Query<'a> = Pin<Box<dyn Future<Output = io::Result<Self::Answer>> + 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<Vec<u8>> = {
|
||||||
|
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<dyn Iterator<Item = io::Result<Vec<u8>>>>;
|
||||||
|
type Query<'a> = Pin<Box<dyn Future<Output = io::Result<Self::Answer>> + 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<dyn Iterator<Item = io::Result<Vec<u8>>>>;
|
||||||
|
type Query<'a> = Pin<Box<dyn Future<Output = io::Result<Self::Answer>> + 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<CachedResolver> for Resolver {
|
||||||
|
fn from(value: CachedResolver) -> Self {
|
||||||
|
Resolver::Real(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MockResolver> 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<TokioResolver>) -> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>),
|
||||||
|
#[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::<Vec<_>>()
|
||||||
|
.join("\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<H>(
|
||||||
|
addr: &impl tokio::net::ToSocketAddrs,
|
||||||
|
handler: Arc<H>,
|
||||||
|
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<H>(
|
||||||
|
socket: TcpStream,
|
||||||
|
handler: Arc<H>,
|
||||||
|
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<H: SmtpHandler> {
|
||||||
|
handler: Arc<H>,
|
||||||
|
max_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H: SmtpHandler> MxDelivService<H> {
|
||||||
|
/// Creates a new [`MxDelivService`].
|
||||||
|
fn new(handler: Arc<H>, max_size: usize) -> Self {
|
||||||
|
Self { handler, max_size }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H: SmtpHandler + 'static> Service<Request<Incoming>> for MxDelivService<H> {
|
||||||
|
type Response = Response<BoxBody<Bytes, Infallible>>;
|
||||||
|
type Error = crate::error::Error;
|
||||||
|
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
||||||
|
|
||||||
|
fn call(&self, req: Request<Incoming>) -> 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<String> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<S: TcpConnect> {
|
||||||
|
config: Config,
|
||||||
|
dns_resolver: Arc<TokioResolver>,
|
||||||
|
dkim_verifier: DkimVerifier,
|
||||||
|
skip_dkim: bool,
|
||||||
|
smtp_connection_pool: Arc<SmtpConnectionPool<S>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> IncomingBeforeQueueHandler<S>
|
||||||
|
where
|
||||||
|
S: TcpStreamTrait + TcpConnect,
|
||||||
|
S::ConnectionContext: Default,
|
||||||
|
{
|
||||||
|
pub fn new(config: Config, skip_dkim: bool) -> Result<Self, crate::error::Error> {
|
||||||
|
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<S> SmtpHandler for IncomingBeforeQueueHandler<S>
|
||||||
|
where
|
||||||
|
S: TcpStreamTrait + TcpConnect,
|
||||||
|
S::ConnectionContext: Default,
|
||||||
|
{
|
||||||
|
type State = ();
|
||||||
|
|
||||||
|
async fn check_data(&self, transaction: &mut Transaction<Self::State>) -> 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.
|
||||||
|
// <https://github.com/chatmail/filtermail/issues/67>
|
||||||
|
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<Self::State>) -> 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::<TcpStream>::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?)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Self, Self::Err> {
|
||||||
|
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<String> = env::args().collect();
|
||||||
|
if args.len() != 3 {
|
||||||
|
eprintln!(
|
||||||
|
"Usage: {} <config_file> <mode>",
|
||||||
|
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::<TcpStream>::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::<TcpStream>::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::<TcpStream>::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(())
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<bool, error::Error> {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<S: TcpConnect> {
|
||||||
|
config: Config,
|
||||||
|
dns_resolver: Arc<TokioResolver>,
|
||||||
|
|
||||||
|
// 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:
|
||||||
|
// <https://github.com/metrics-rs/quanta/issues/111>
|
||||||
|
send_rate_limiter: RateLimiter<
|
||||||
|
String,
|
||||||
|
governor::state::keyed::DashMapStateStore<String>,
|
||||||
|
MonotonicClock,
|
||||||
|
NoOpMiddleware<std::time::Instant>,
|
||||||
|
>,
|
||||||
|
smtp_connection_pool: Arc<SmtpConnectionPool<S>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> OutgoingBeforeQueueHandler<S>
|
||||||
|
where
|
||||||
|
S: TcpStreamTrait + TcpConnect,
|
||||||
|
S::ConnectionContext: Default,
|
||||||
|
{
|
||||||
|
pub fn new(config: Config) -> Result<Self, crate::error::Error> {
|
||||||
|
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<S> SmtpHandler for OutgoingBeforeQueueHandler<S>
|
||||||
|
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()) {
|
||||||
|
// "<example@example.org> 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<Self::State>) -> 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<Self::State>) -> 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<Self::State>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<S> {
|
||||||
|
pub stream: BufStream<SmtpStream<S>>,
|
||||||
|
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<S>
|
||||||
|
where
|
||||||
|
S: TcpStreamTrait + TcpConnect,
|
||||||
|
{
|
||||||
|
pool: Arc<retainer::Cache<(String, u16), SmtpConnection<S>>>,
|
||||||
|
monitor_handle: JoinHandle<()>,
|
||||||
|
context: S::ConnectionContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> SmtpConnectionPool<S>
|
||||||
|
where
|
||||||
|
S: TcpStreamTrait + TcpConnect,
|
||||||
|
{
|
||||||
|
/// Creates a new connection pool and starts the cache monitoring task.
|
||||||
|
pub fn new(context: S::ConnectionContext) -> Arc<Self> {
|
||||||
|
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<SmtpConnection<S>> {
|
||||||
|
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<S>) {
|
||||||
|
// similarly to postfix default -> 100s max idle time.
|
||||||
|
self.pool
|
||||||
|
.insert(
|
||||||
|
(address.to_string(), port),
|
||||||
|
connection,
|
||||||
|
Duration::from_secs(100),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: TcpConnect> Drop for SmtpConnectionPool<S> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.monitor_handle.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [`TcpStream`] wrapper used for SMTP communication.
|
||||||
|
#[expect(clippy::large_enum_variant)]
|
||||||
|
pub enum SmtpStream<S> {
|
||||||
|
/// A plain TCP stream.
|
||||||
|
Plain(Pin<Box<TimeoutStream<S>>>),
|
||||||
|
/// A TLS-encrypted stream.
|
||||||
|
Tls(tokio_rustls::TlsStream<Pin<Box<TimeoutStream<S>>>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: TcpStreamTrait> SmtpStream<S> {
|
||||||
|
/// 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<std::net::SocketAddr> {
|
||||||
|
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:
|
||||||
|
/// - `<ip>:<port>` if `address` is an IP matching underlying TCP connection.
|
||||||
|
/// - `<address>[<ip>:<port>]` otherwise.
|
||||||
|
///
|
||||||
|
/// `<ip>` is either `<ipv4>` or `[<ipv6>]`.
|
||||||
|
///
|
||||||
|
/// Infallible, fallbacks to `<address>[?:?]` 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<std::net::SocketAddr>) -> 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<ClientSessionMemoryCache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: TcpStreamTrait> AsyncWrite for SmtpStream<S> {
|
||||||
|
fn poll_write(
|
||||||
|
self: Pin<&mut Self>,
|
||||||
|
cx: &mut Context<'_>,
|
||||||
|
buf: &[u8],
|
||||||
|
) -> Poll<std::io::Result<usize>> {
|
||||||
|
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<std::io::Result<()>> {
|
||||||
|
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<std::io::Result<()>> {
|
||||||
|
match self.get_mut() {
|
||||||
|
SmtpStream::Plain(stream) => Pin::new(stream).poll_shutdown(cx),
|
||||||
|
SmtpStream::Tls(stream) => Pin::new(stream).poll_shutdown(cx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: TcpStreamTrait> AsyncRead for SmtpStream<S> {
|
||||||
|
fn poll_read(
|
||||||
|
self: Pin<&mut Self>,
|
||||||
|
cx: &mut Context<'_>,
|
||||||
|
buf: &mut ReadBuf<'_>,
|
||||||
|
) -> Poll<std::io::Result<()>> {
|
||||||
|
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<TokioResolver>,
|
||||||
|
) -> Result<Vec<std::net::SocketAddr>, 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<S>(
|
||||||
|
address: &str,
|
||||||
|
port: u16,
|
||||||
|
dns_resolver: Arc<TokioResolver>,
|
||||||
|
context: S::ConnectionContext,
|
||||||
|
) -> Result<S, crate::error::Error>
|
||||||
|
where
|
||||||
|
S: TcpStreamTrait + TcpConnect,
|
||||||
|
{
|
||||||
|
let mut set: JoinSet<tokio::io::Result<S>> = 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<S> = 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<TlsConfig>,
|
||||||
|
|
||||||
|
/// 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<S>(
|
||||||
|
address: &str,
|
||||||
|
port: u16,
|
||||||
|
envelope: &Envelope,
|
||||||
|
config: ClientConfig<'_>,
|
||||||
|
dns_resolver: Arc<TokioResolver>,
|
||||||
|
pool: Arc<SmtpConnectionPool<S>>,
|
||||||
|
) -> 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<SocketAddr>,
|
||||||
|
#[case] host: &str,
|
||||||
|
#[case] expected: &str,
|
||||||
|
) {
|
||||||
|
let result = SmtpStream::<TcpStream>::format_host_inner(host, socket_addr);
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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<String>,
|
||||||
|
/// Mail data as transmitted over SMTP/LMTP.
|
||||||
|
///
|
||||||
|
/// Described in <https://www.rfc-editor.org/rfc/rfc5321.html#section-2.3.9>.
|
||||||
|
///
|
||||||
|
/// It MUST end with `<CRLF>`, contain no bare `<CR>` or `<LF>`
|
||||||
|
/// and have all `<CRLF>.` sequences escaped with `.` according to
|
||||||
|
/// <https://www.rfc-editor.org/rfc/rfc5321.html#section-4.5.2>.
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<S: Debug + Default> {
|
||||||
|
pub envelope: Envelope,
|
||||||
|
pub state: S,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if mail data is valid.
|
||||||
|
fn is_valid_data(data: &[u8]) -> bool {
|
||||||
|
// DATA must end with <CRLF>.
|
||||||
|
//
|
||||||
|
// Otherwise it is not possible to reinject it as is into SMTP/LMTP
|
||||||
|
// without adding <CRLF> at the end and modifying the message.
|
||||||
|
if !data.ends_with(b"\r\n") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for bare `<CR>` and `<LF>`.
|
||||||
|
// <https://www.rfc-editor.org/rfc/rfc5321.html#section-2.3.8>
|
||||||
|
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<Self::State>) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reinjects the mail back to postfix.
|
||||||
|
///
|
||||||
|
/// Default implementation is no-op.
|
||||||
|
async fn reinject_mail(&self, _transaction: &Transaction<Self::State>) -> 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<Self::State>,
|
||||||
|
) -> 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<Self::State>) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the end of DATA command. Called after receiving the final dot.
|
||||||
|
async fn handle_data_dot(
|
||||||
|
&self,
|
||||||
|
transaction: &mut Transaction<Self::State>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
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<H>(
|
||||||
|
addr: &impl tokio::net::ToSocketAddrs,
|
||||||
|
handler: Arc<H>,
|
||||||
|
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<S, H>(
|
||||||
|
stream: S,
|
||||||
|
handler: Arc<H>,
|
||||||
|
max_size: usize,
|
||||||
|
auto_quit: bool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>>
|
||||||
|
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 <CR><LF>.<CR><LF>\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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:<sender@here>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<a1@localhost>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<a2@localhost>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<b1@[127.0.0.1]>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<b2@[127.0.0.1]>
|
||||||
|
< 250 OK
|
||||||
|
> DATA
|
||||||
|
< 354 End data with <CR><LF>.<CR><LF>
|
||||||
|
> 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:<sender@here>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<a1@localhost>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<a2@localhost>
|
||||||
|
< 250 OK
|
||||||
|
> DATA
|
||||||
|
< 354 End data with <CR><LF>.<CR><LF>
|
||||||
|
> message
|
||||||
|
.
|
||||||
|
< 250 OK
|
||||||
|
|
||||||
|
[filtermail-transport -> destination B]
|
||||||
|
< 220 filtermail SMTP
|
||||||
|
> EHLO example.org
|
||||||
|
< 250-filtermail
|
||||||
|
250-8BITMIME
|
||||||
|
250 OK
|
||||||
|
> MAIL FROM:<sender@here>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<b1@[127.0.0.1]>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<b2@[127.0.0.1]>
|
||||||
|
< 250 OK
|
||||||
|
> DATA
|
||||||
|
< 354 End data with <CR><LF>.<CR><LF>
|
||||||
|
> message
|
||||||
|
.
|
||||||
|
< 250 OK
|
||||||
@@ -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:<sender@here>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<a1@localhost>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<b1@[127.0.0.1]>
|
||||||
|
< 250 OK
|
||||||
|
> DATA
|
||||||
|
< 354 End data with <CR><LF>.<CR><LF>
|
||||||
|
> 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:<sender@here>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<a1@localhost>
|
||||||
|
< 250 OK
|
||||||
|
> DATA
|
||||||
|
< 354 End data with <CR><LF>.<CR><LF>
|
||||||
|
> message
|
||||||
|
.
|
||||||
|
< 250 OK
|
||||||
|
|
||||||
|
|
||||||
|
TRANSACTION 2
|
||||||
|
[postfix -> filtermail-transport]
|
||||||
|
< 220 filtermail SMTP
|
||||||
|
> LHLO postfix
|
||||||
|
< 250-filtermail
|
||||||
|
250-8BITMIME
|
||||||
|
250 OK
|
||||||
|
> MAIL FROM:<sender@here>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<b1@[127.0.0.1]>
|
||||||
|
< 250 OK
|
||||||
|
> DATA
|
||||||
|
< 354 End data with <CR><LF>.<CR><LF>
|
||||||
|
> message
|
||||||
|
> .
|
||||||
|
< 250 OK (SMTP)
|
||||||
|
|
||||||
|
[filtermail-transport -> destination B]
|
||||||
|
< 220 filtermail SMTP
|
||||||
|
> EHLO example.org
|
||||||
|
< 250-filtermail
|
||||||
|
250-8BITMIME
|
||||||
|
250 OK
|
||||||
|
> MAIL FROM:<sender@here>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<b1@[127.0.0.1]>
|
||||||
|
< 250 OK
|
||||||
|
> DATA
|
||||||
|
< 354 End data with <CR><LF>.<CR><LF>
|
||||||
|
> message
|
||||||
|
.
|
||||||
|
< 250 OK
|
||||||
+51
@@ -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:<sender@here>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<a1@localhost>
|
||||||
|
< 250 OK
|
||||||
|
> DATA
|
||||||
|
< 354 End data with <CR><LF>.<CR><LF>
|
||||||
|
> message
|
||||||
|
> .
|
||||||
|
< 250 OK (SMTP)
|
||||||
|
|
||||||
|
[filtermail-transport -> destination A]
|
||||||
|
< 220 filtermail SMTP
|
||||||
|
> EHLO example.org
|
||||||
|
< 250-filtermail
|
||||||
|
250-8BITMIME
|
||||||
|
250 OK
|
||||||
|
> MAIL FROM:<sender@here>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<a1@localhost>
|
||||||
|
< 250 OK
|
||||||
|
> DATA
|
||||||
|
< 354 End data with <CR><LF>.<CR><LF>
|
||||||
|
> message
|
||||||
|
.
|
||||||
|
< 250 OK
|
||||||
|
|
||||||
|
|
||||||
|
TRANSACTION 2
|
||||||
|
[postfix -> filtermail-transport]
|
||||||
|
< 220 filtermail SMTP
|
||||||
|
> LHLO postfix
|
||||||
|
< 250-filtermail
|
||||||
|
250-8BITMIME
|
||||||
|
250 OK
|
||||||
|
> MAIL FROM:<sender@here>
|
||||||
|
< 250 OK
|
||||||
|
> RCPT TO:<b1@[127.0.0.1]>
|
||||||
|
< 250 OK
|
||||||
|
> DATA
|
||||||
|
< 421 Transport busy
|
||||||
@@ -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<std::net::SocketAddr>;
|
||||||
|
|
||||||
|
/// 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<A: ToSocketAddrs + Send>(
|
||||||
|
addr: A,
|
||||||
|
context: Self::ConnectionContext,
|
||||||
|
) -> std::io::Result<Self>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<std::net::SocketAddr> {
|
||||||
|
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<A: ToSocketAddrs + Send>(addr: A, _: ()) -> std::io::Result<Self> {
|
||||||
|
TcpStream::connect(addr).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
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<String>, 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<std::io::Result<usize>> {
|
||||||
|
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<std::io::Result<()>> {
|
||||||
|
Pin::new(&mut self.get_mut().inner).poll_flush(cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||||
|
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<std::io::Result<()>> {
|
||||||
|
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<SocketAddr> {
|
||||||
|
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<String>;
|
||||||
|
|
||||||
|
async fn connect<A: ToSocketAddrs + Send>(
|
||||||
|
addr: A,
|
||||||
|
tx: Sender<String>,
|
||||||
|
) -> std::io::Result<Self> {
|
||||||
|
let inner = TcpStream::connect(addr).await?;
|
||||||
|
Ok(Self::new(inner, tx, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IO>(
|
||||||
|
hostname: &str,
|
||||||
|
stream: IO,
|
||||||
|
resumption_store: Arc<ClientSessionMemoryCache>,
|
||||||
|
dangerous_no_cert_verification: bool,
|
||||||
|
) -> Result<TlsStream<IO>, 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<ClientSessionMemoryCache>,
|
||||||
|
dangerous_no_cert_verification: bool,
|
||||||
|
) -> Result<rustls::ClientConfig, crate::error::Error> {
|
||||||
|
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 <https://www.rfc-editor.org/rfc/rfc8446#section-2.2>.
|
||||||
|
//
|
||||||
|
// Obsolete TLS 1.2 mechanisms defined in RFC 5246
|
||||||
|
// and RFC 5077 have worse security
|
||||||
|
// and are not worth increasing
|
||||||
|
// attack surface: <https://words.filippo.io/we-need-to-talk-about-session-tickets/>.
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||||
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
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<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
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<rustls::SignatureScheme> {
|
||||||
|
let provider = rustls::crypto::aws_lc_rs::default_provider();
|
||||||
|
provider
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<S: TcpConnect> {
|
||||||
|
workers: WorkerPool<S>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> TransportHandler<S>
|
||||||
|
where
|
||||||
|
S: TcpStreamTrait + TcpConnect,
|
||||||
|
S::ConnectionContext: Default,
|
||||||
|
{
|
||||||
|
/// Creates a new [`TransportHandler`].
|
||||||
|
pub fn new(config: Config) -> Result<Self, crate::error::Error> {
|
||||||
|
let workers = WorkerPool::new(config)?;
|
||||||
|
|
||||||
|
Ok(Self { workers })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as [`Self::new`], but lets you set worker queue size and pool capacity.
|
||||||
|
///
|
||||||
|
/// Only used for tests.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn with_queue_size_and_pool_capacity(
|
||||||
|
config: Config,
|
||||||
|
queue_size: usize,
|
||||||
|
pool_capacity: usize,
|
||||||
|
) -> Result<Self, crate::error::Error> {
|
||||||
|
let workers =
|
||||||
|
WorkerPool::with_queue_size_and_pool_capacity(config, queue_size, pool_capacity)?;
|
||||||
|
|
||||||
|
Ok(Self { workers })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct TransactionState {
|
||||||
|
permits: BTreeMap<AddressDomain, OwnedPermit<WorkerMessage>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<S> SmtpHandler for TransportHandler<S>
|
||||||
|
where
|
||||||
|
S: TcpStreamTrait + TcpConnect,
|
||||||
|
S::ConnectionContext: Default,
|
||||||
|
{
|
||||||
|
type State = TransactionState;
|
||||||
|
|
||||||
|
fn handle_rcpt_to(
|
||||||
|
&self,
|
||||||
|
address: &str,
|
||||||
|
transaction: &mut Transaction<Self::State>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let domain = AddressDomain::from_str(address).map_err(|e| e.smtp_response())?;
|
||||||
|
|
||||||
|
if transaction.state.permits.contains_key(&domain) {
|
||||||
|
// We already acquired a permit for this domain
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::trace!("Trying to acquire a permit for {domain} worker...",);
|
||||||
|
if let Some(permit) = self.workers.get_permit(&domain) {
|
||||||
|
transaction.state.permits.insert(domain, permit);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_data_start(&self, transaction: &Transaction<Self::State>) -> Result<(), String> {
|
||||||
|
// We want to prevent needlessly sending data from postfix to filtermail,
|
||||||
|
// so we fail here if we didn't get any permit.
|
||||||
|
//
|
||||||
|
// Examplary scenario:
|
||||||
|
// Consider destinations A and B, where A is unavailable.
|
||||||
|
// We are sending a message to a group of 1@A, 2@A, 1@B, 2@B.
|
||||||
|
// After handle_rcpt_to on every recipient, we end up with a permit for domain B (A fails).
|
||||||
|
// handle_data_start passes and mail data is transmitted to filtermail.
|
||||||
|
// Delivery to B is performed; 1@B and 2@B receive message and a message to 1@A and 2@A
|
||||||
|
// is deferred.
|
||||||
|
// After some time the message is retried, now we only try to acquire permit for A,
|
||||||
|
// but fail -> empty `transaction.state.permits`
|
||||||
|
// handle_data_start fails and mail data is not sent to filtermail.
|
||||||
|
// This greatly reduces RAM usage, as unavailable destination can cause large numbers of
|
||||||
|
// deferred mails to be constantly retried.
|
||||||
|
|
||||||
|
if transaction.state.permits.is_empty() {
|
||||||
|
return Err(TRANSPORT_BUSY_421.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the DATA command and returns LMTP responses as single string.
|
||||||
|
///
|
||||||
|
/// Never returns an error, as LMTP response is composite.
|
||||||
|
async fn handle_data_dot(
|
||||||
|
&self,
|
||||||
|
transaction: &mut Transaction<Self::State>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut domain_rcpts_map = BTreeMap::new();
|
||||||
|
|
||||||
|
for rcpt in &transaction.envelope.rcpt_to {
|
||||||
|
let domain = AddressDomain::from_str(rcpt)
|
||||||
|
// Currently we cancel all transactions if any recipient address is invalid.
|
||||||
|
.map_err(|e| e.lmtp_response(transaction.envelope.rcpt_to.len()))?;
|
||||||
|
domain_rcpts_map
|
||||||
|
.entry(domain)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(rcpt.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// one transaction per domain
|
||||||
|
let mut transactions = JoinSet::new();
|
||||||
|
let mut task_id_domain_map = BTreeMap::new();
|
||||||
|
|
||||||
|
for (rcpt_domain, rcpts) in &domain_rcpts_map {
|
||||||
|
let domain_envelope = {
|
||||||
|
let mut envelope = transaction.envelope.clone();
|
||||||
|
envelope.rcpt_to = rcpts.clone();
|
||||||
|
envelope
|
||||||
|
};
|
||||||
|
let receiver_task_id =
|
||||||
|
if let Some(permit) = transaction.state.permits.remove(rcpt_domain) {
|
||||||
|
let (message, receiver) = WorkerMessage::new(domain_envelope);
|
||||||
|
permit.send(message);
|
||||||
|
transactions.spawn(receiver).id()
|
||||||
|
} else {
|
||||||
|
transactions
|
||||||
|
.spawn(async move { Ok(Err(TRANSPORT_BUSY_421.to_string())) })
|
||||||
|
.id()
|
||||||
|
};
|
||||||
|
task_id_domain_map.insert(receiver_task_id, rcpt_domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rcpt_response_map = BTreeMap::new();
|
||||||
|
while let Some(result) = transactions.join_next_with_id().await {
|
||||||
|
let domain = match &result {
|
||||||
|
Ok((id, _)) => task_id_domain_map.remove(id),
|
||||||
|
Err(e) => task_id_domain_map.remove(&e.id()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let smtp_response = match result {
|
||||||
|
Ok((_, Ok(Ok(resp)))) | Ok((_, Ok(Err(resp)))) => resp,
|
||||||
|
Ok((_, Err(_))) => {
|
||||||
|
log::error!(
|
||||||
|
"Delivery to {} failed due to a dead worker!",
|
||||||
|
domain.map(AsRef::as_ref).unwrap_or("<unknown>")
|
||||||
|
);
|
||||||
|
if let Some(domain) = domain {
|
||||||
|
self.workers.cleanup(domain);
|
||||||
|
}
|
||||||
|
LOCAL_ERROR_451.to_string()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"Failed to join task while delivering to {}: {e}",
|
||||||
|
domain.map(AsRef::as_ref).unwrap_or("<unknown>")
|
||||||
|
);
|
||||||
|
LOCAL_ERROR_451.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(domain) = domain
|
||||||
|
&& let Some(rcpts) = domain_rcpts_map.get(domain)
|
||||||
|
{
|
||||||
|
for rcpt in rcpts {
|
||||||
|
rcpt_response_map.insert(rcpt, smtp_response.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// compose lmtp response...
|
||||||
|
let ordered_responses: Vec<String> = transaction
|
||||||
|
.envelope
|
||||||
|
.rcpt_to
|
||||||
|
.iter()
|
||||||
|
.map(|rcpt| {
|
||||||
|
rcpt_response_map
|
||||||
|
.remove(rcpt)
|
||||||
|
.unwrap_or_else(|| LOCAL_ERROR_451.to_string())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(ordered_responses.join("\r\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::smtp_client::SmtpConnectionPool;
|
||||||
|
use crate::smtp_server::{Envelope, MockHandler, run_smtp_server};
|
||||||
|
use crate::tcp::rec_stream::RecTcpStream;
|
||||||
|
use rstest::{fixture, rstest};
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use testresult::TestResult;
|
||||||
|
use tokio::net::{TcpSocket, TcpStream};
|
||||||
|
use tokio::sync::mpsc::Receiver;
|
||||||
|
|
||||||
|
const FILTERMAIL_IP: &str = "127.0.0.1";
|
||||||
|
const FILTERMAIL_PORT: u16 = 10083;
|
||||||
|
const FILTERMAIL_ADDR: (&str, u16) = (FILTERMAIL_IP, FILTERMAIL_PORT);
|
||||||
|
|
||||||
|
/// Spawns a mockup SMTP server that accepts anything on `localhost:10025`.
|
||||||
|
///
|
||||||
|
/// Returns a receiver that receives records of SMTP conversations.
|
||||||
|
fn spawn_mock_mta() -> TestResult<Receiver<String>> {
|
||||||
|
let socket = TcpSocket::new_v4()?;
|
||||||
|
socket.set_nodelay(true)?;
|
||||||
|
socket.set_reuseport(true)?;
|
||||||
|
socket.bind("127.0.0.1:10025".parse()?)?;
|
||||||
|
let remote_listener = socket.listen(8)?;
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(128);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok((stream, _)) = remote_listener.accept().await {
|
||||||
|
let tx_clone = tx.clone();
|
||||||
|
let rec_stream = RecTcpStream::new(stream, tx_clone, true);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
crate::smtp_server::handle_connection(
|
||||||
|
rec_stream,
|
||||||
|
Arc::new(MockHandler),
|
||||||
|
9999, // arbitrary
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns filtermail-transport.
|
||||||
|
///
|
||||||
|
/// Returns a pointer to the underlying handler.
|
||||||
|
fn spawn_filtermail_transport(
|
||||||
|
queue_size: usize,
|
||||||
|
pool_capacity: usize,
|
||||||
|
) -> TestResult<Arc<TransportHandler<TcpStream>>> {
|
||||||
|
let config = Config::default();
|
||||||
|
let transport = Arc::new(TransportHandler::with_queue_size_and_pool_capacity(
|
||||||
|
config.clone(),
|
||||||
|
queue_size,
|
||||||
|
pool_capacity,
|
||||||
|
)?);
|
||||||
|
tokio::spawn(run_smtp_server(
|
||||||
|
&FILTERMAIL_ADDR,
|
||||||
|
transport.clone(),
|
||||||
|
config.max_message_size,
|
||||||
|
));
|
||||||
|
Ok(transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends envelope over LMTP.
|
||||||
|
///
|
||||||
|
/// Returns a recorded LMTP conversation.
|
||||||
|
///
|
||||||
|
/// Does not fail on negative response.
|
||||||
|
async fn lmtp_send(envelope: &Envelope) -> TestResult<String> {
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::channel(128);
|
||||||
|
let client_config = crate::smtp_client::ClientConfig {
|
||||||
|
client_hostname: "postfix",
|
||||||
|
tls_config: None,
|
||||||
|
lmtp: true,
|
||||||
|
};
|
||||||
|
let _ = crate::smtp_client::send(
|
||||||
|
FILTERMAIL_IP,
|
||||||
|
FILTERMAIL_PORT,
|
||||||
|
envelope,
|
||||||
|
client_config,
|
||||||
|
Arc::new(crate::utils::build_resolver()?),
|
||||||
|
SmtpConnectionPool::<RecTcpStream>::new(tx),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let record = rx.recv().await.unwrap();
|
||||||
|
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[fixture]
|
||||||
|
fn addrs1() -> Vec<String> {
|
||||||
|
let mut vec = Vec::new();
|
||||||
|
for idx in 0..5 {
|
||||||
|
vec.push(format!("{idx}@one.example.org"))
|
||||||
|
}
|
||||||
|
vec
|
||||||
|
}
|
||||||
|
|
||||||
|
#[fixture]
|
||||||
|
fn addrs2() -> Vec<String> {
|
||||||
|
let mut vec = Vec::new();
|
||||||
|
for idx in 0..5 {
|
||||||
|
vec.push(format!("{idx}@two.example.org"))
|
||||||
|
}
|
||||||
|
vec
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_rcpt_to_and_start_data(addrs1: Vec<String>, addrs2: Vec<String>) -> TestResult {
|
||||||
|
let transport_handler = TransportHandler::<TcpStream>::with_queue_size_and_pool_capacity(
|
||||||
|
Config::default(),
|
||||||
|
1,
|
||||||
|
500,
|
||||||
|
)?;
|
||||||
|
let domain1 = AddressDomain::from_str(addrs1.first().unwrap())?;
|
||||||
|
let domain2 = AddressDomain::from_str(addrs2.first().unwrap())?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut trans_1 = Transaction::default();
|
||||||
|
let mut trans_2 = Transaction::default();
|
||||||
|
let mut trans_3 = Transaction::default();
|
||||||
|
|
||||||
|
transport_handler.handle_rcpt_to(addrs1.first().unwrap(), &mut trans_1)?;
|
||||||
|
assert!(trans_1.state.permits.contains_key(&domain1));
|
||||||
|
|
||||||
|
// Within one transaction, we only use one worker permit, so queue_size=1 is enough.
|
||||||
|
transport_handler.handle_rcpt_to(addrs1.get(1).unwrap(), &mut trans_1)?;
|
||||||
|
assert!(trans_1.state.permits.contains_key(&domain1));
|
||||||
|
|
||||||
|
// However, a second transaction with the same domain won't get a permit.
|
||||||
|
transport_handler.handle_rcpt_to(addrs1.get(2).unwrap(), &mut trans_2)?;
|
||||||
|
assert!(!trans_2.state.permits.contains_key(&domain1));
|
||||||
|
|
||||||
|
// Different domain will work though, as it uses a separate worker, with its own queue.
|
||||||
|
transport_handler.handle_rcpt_to(addrs2.first().unwrap(), &mut trans_2)?;
|
||||||
|
assert!(trans_2.state.permits.contains_key(&domain2));
|
||||||
|
|
||||||
|
// Third transaction won't get any permits.
|
||||||
|
transport_handler.handle_rcpt_to(addrs1.get(3).unwrap(), &mut trans_3)?;
|
||||||
|
transport_handler.handle_rcpt_to(addrs2.get(2).unwrap(), &mut trans_3)?;
|
||||||
|
assert!(!trans_3.state.permits.contains_key(&domain1));
|
||||||
|
assert!(!trans_3.state.permits.contains_key(&domain2));
|
||||||
|
|
||||||
|
// all permits granted -> accept DATA command
|
||||||
|
assert_eq!(transport_handler.handle_data_start(&trans_1), Ok(()));
|
||||||
|
|
||||||
|
// some permits granted -> accept DATA command
|
||||||
|
assert_eq!(transport_handler.handle_data_start(&trans_2), Ok(()));
|
||||||
|
|
||||||
|
// no permits granted -> reject
|
||||||
|
assert!(transport_handler.handle_data_start(&trans_3).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transactions (and owned by them permits) going out of scope frees the queues.
|
||||||
|
let mut trans_4 = Transaction::default();
|
||||||
|
transport_handler.handle_rcpt_to(addrs1.first().unwrap(), &mut trans_4)?;
|
||||||
|
transport_handler.handle_rcpt_to(addrs2.first().unwrap(), &mut trans_4)?;
|
||||||
|
assert!(trans_4.state.permits.contains_key(&domain1));
|
||||||
|
assert!(trans_4.state.permits.contains_key(&domain2));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[serial]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_smtp_send_mail() -> TestResult {
|
||||||
|
let mut remote_mta = spawn_mock_mta()?;
|
||||||
|
spawn_filtermail_transport(1, 2)?;
|
||||||
|
|
||||||
|
let envelope = Envelope {
|
||||||
|
mail_from: "sender@here".to_string(),
|
||||||
|
rcpt_to: vec![
|
||||||
|
// Taking advantage of the fact that localhost and [127.0.0.1] are recognized as
|
||||||
|
// different destinations.
|
||||||
|
"a1@localhost".to_string(),
|
||||||
|
"a2@localhost".to_string(),
|
||||||
|
"b1@[127.0.0.1]".to_string(),
|
||||||
|
"b2@[127.0.0.1]".to_string(),
|
||||||
|
],
|
||||||
|
data: "message\r\n".as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let record = lmtp_send(&envelope).await?;
|
||||||
|
|
||||||
|
let mut remote_records = [
|
||||||
|
remote_mta.recv().await.unwrap(),
|
||||||
|
remote_mta.recv().await.unwrap(),
|
||||||
|
];
|
||||||
|
remote_records.sort_by_key(|s| s.contains("b1@[127.0.0.1]"));
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
assert!(remote_mta.is_empty());
|
||||||
|
|
||||||
|
insta::assert_snapshot!(format!(
|
||||||
|
"[postfix -> filtermail-transport]\r\n{record}\r\n\
|
||||||
|
[filtermail-transport -> destination A]\r\n{}\r\n\
|
||||||
|
[filtermail-transport -> destination B]\r\n{}",
|
||||||
|
remote_records[0], remote_records[1]
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[serial]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_smtp_send_mail_defer() -> TestResult {
|
||||||
|
let mut remote_mta = spawn_mock_mta()?;
|
||||||
|
let transport = spawn_filtermail_transport(1, 500)?;
|
||||||
|
|
||||||
|
let mut envelope = Envelope {
|
||||||
|
mail_from: "sender@here".to_string(),
|
||||||
|
rcpt_to: vec!["a1@localhost".to_string(), "b1@[127.0.0.1]".to_string()],
|
||||||
|
data: "message\r\n".as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (record_postfix_1, record_filtermail_1) = {
|
||||||
|
// simulate full queue on [127.0.0.1] worker
|
||||||
|
let _permit = transport
|
||||||
|
.workers
|
||||||
|
.get_permit(&AddressDomain::Literal("127.0.0.1".to_string()));
|
||||||
|
|
||||||
|
let record_postfix = lmtp_send(&envelope).await?;
|
||||||
|
|
||||||
|
let record_filtermail = remote_mta.recv().await.unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
assert!(remote_mta.is_empty());
|
||||||
|
(record_postfix, record_filtermail)
|
||||||
|
};
|
||||||
|
|
||||||
|
// retry deferred
|
||||||
|
envelope.rcpt_to.remove(0);
|
||||||
|
let record_postfix_2 = lmtp_send(&envelope).await?;
|
||||||
|
let record_filtermail_2 = remote_mta.recv().await.unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
assert!(remote_mta.is_empty());
|
||||||
|
|
||||||
|
insta::assert_snapshot!(format!(
|
||||||
|
"TRANSACTION 1\r\n\
|
||||||
|
[postfix -> filtermail-transport]\r\n{record_postfix_1}\r\n\
|
||||||
|
[filtermail-transport -> destination A]\r\n{record_filtermail_1}\r\n\r\n\
|
||||||
|
TRANSACTION 2\r\n\
|
||||||
|
[postfix -> filtermail-transport]\r\n{record_postfix_2}\r\n\
|
||||||
|
[filtermail-transport -> destination B]\r\n{record_filtermail_2}",
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[serial]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_smtp_send_mail_worker_cap_exceeded() -> TestResult {
|
||||||
|
let mut remote_mta = spawn_mock_mta()?;
|
||||||
|
spawn_filtermail_transport(30, 1)?;
|
||||||
|
|
||||||
|
let envelope_1 = Envelope {
|
||||||
|
mail_from: "sender@here".to_string(),
|
||||||
|
rcpt_to: vec!["a1@localhost".to_string()],
|
||||||
|
data: "message\r\n".as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let envelope_2 = Envelope {
|
||||||
|
mail_from: "sender@here".to_string(),
|
||||||
|
rcpt_to: vec!["b1@[127.0.0.1]".to_string()],
|
||||||
|
data: "message\r\n".as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1
|
||||||
|
let (record_postfix_1, record_filtermail_1) = {
|
||||||
|
let record_postfix = lmtp_send(&envelope_1).await?;
|
||||||
|
let record_filtermail = remote_mta.recv().await.unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
assert!(remote_mta.is_empty());
|
||||||
|
(record_postfix, record_filtermail)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2
|
||||||
|
let record_postfix_2 = lmtp_send(&envelope_2).await?;
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
assert!(remote_mta.is_empty());
|
||||||
|
|
||||||
|
insta::assert_snapshot!(format!(
|
||||||
|
"TRANSACTION 1\r\n\
|
||||||
|
[postfix -> filtermail-transport]\r\n{record_postfix_1}\r\n\
|
||||||
|
[filtermail-transport -> destination A]\r\n{record_filtermail_1}\r\n\r\n\
|
||||||
|
TRANSACTION 2\r\n\
|
||||||
|
[postfix -> filtermail-transport]\r\n{record_postfix_2}"
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
use crate::tls;
|
||||||
|
use hyper::body::Bytes;
|
||||||
|
use hyper_rustls::HttpsConnector;
|
||||||
|
use hyper_util::client::legacy::connect::HttpConnector;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio_rustls::rustls;
|
||||||
|
|
||||||
|
/// Cheaply clonable HTTPS client.
|
||||||
|
///
|
||||||
|
/// Holds regular secure variant and relaxed - without certificate verification.
|
||||||
|
///
|
||||||
|
/// Connection pool handled internally by [`hyper_util::client::legacy::Client`].
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct HttpsClient {
|
||||||
|
pub secure: hyper_util::client::legacy::Client<
|
||||||
|
HttpsConnector<HttpConnector>,
|
||||||
|
http_body_util::Full<Bytes>,
|
||||||
|
>,
|
||||||
|
pub relaxed: hyper_util::client::legacy::Client<
|
||||||
|
HttpsConnector<HttpConnector>,
|
||||||
|
http_body_util::Full<Bytes>,
|
||||||
|
>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpsClient {
|
||||||
|
/// Creates a new `[HttpsClient]`.
|
||||||
|
pub fn new(
|
||||||
|
tls_resumption_store: Arc<rustls::client::ClientSessionMemoryCache>,
|
||||||
|
) -> Result<Self, crate::error::Error> {
|
||||||
|
let tls_client_config = tls::configure_rustls(tls_resumption_store.clone(), false)?;
|
||||||
|
let https_connector = hyper_rustls::HttpsConnectorBuilder::new()
|
||||||
|
.with_tls_config(tls_client_config)
|
||||||
|
.https_only()
|
||||||
|
.enable_http1()
|
||||||
|
.enable_http2()
|
||||||
|
.build();
|
||||||
|
let https_client =
|
||||||
|
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
|
||||||
|
.build(https_connector);
|
||||||
|
|
||||||
|
let tls_client_config_relaxed = tls::configure_rustls(tls_resumption_store, true)?;
|
||||||
|
let https_connector_relaxed = hyper_rustls::HttpsConnectorBuilder::new()
|
||||||
|
.with_tls_config(tls_client_config_relaxed)
|
||||||
|
.https_only()
|
||||||
|
.enable_http1()
|
||||||
|
.enable_http2()
|
||||||
|
.build();
|
||||||
|
let https_client_relaxed =
|
||||||
|
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
|
||||||
|
.build(https_connector_relaxed);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
secure: https_client,
|
||||||
|
relaxed: https_client_relaxed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use crate::smtp_client::{SmtpConnectionPool, TlsConfig};
|
||||||
|
use crate::smtp_responses::{OK_HTTPS_250, OK_SMTP_250};
|
||||||
|
use crate::smtp_server::Envelope;
|
||||||
|
use crate::tcp::{TcpConnect, TcpStreamTrait};
|
||||||
|
use crate::transport::{HEADER_MAIL_FROM, HEADER_RCPT_TO, https_client::HttpsClient};
|
||||||
|
use crate::utils::{AddressDomain, build_resolver};
|
||||||
|
use hickory_resolver::TokioResolver;
|
||||||
|
use hickory_resolver::proto::rr::RData;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use hyper::body::Bytes;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::mpsc::OwnedPermit;
|
||||||
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
use tokio::task;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
use tokio_rustls::rustls;
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
const SMTP_PORT: u16 = 25;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
const SMTP_SKIP_TLS: bool = false;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
const SMTP_PORT: u16 = 10025;
|
||||||
|
#[cfg(test)]
|
||||||
|
const SMTP_SKIP_TLS: bool = true;
|
||||||
|
|
||||||
|
/// Message queue size per [`Worker`].
|
||||||
|
///
|
||||||
|
/// If a queue to a single destination reaches this limit,
|
||||||
|
/// all new messages will be immediately deferred.
|
||||||
|
const PER_DESTINATION_QUEUE_SIZE: usize = 30;
|
||||||
|
|
||||||
|
/// Max number of [`Worker`]s operating at the same time.
|
||||||
|
const MAX_WORKERS: usize = 500;
|
||||||
|
|
||||||
|
/// How long a worker can stay idle.
|
||||||
|
///
|
||||||
|
/// Exceeding this value will cause a worker shutdown.
|
||||||
|
const WORKER_KEEPALIVE_DURATION: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
type SMTPResponse = Result<String, String>;
|
||||||
|
|
||||||
|
pub struct WorkerPool<S: TcpConnect> {
|
||||||
|
inner: Arc<RwLock<BTreeMap<AddressDomain, Arc<Worker>>>>,
|
||||||
|
client_hostname: String,
|
||||||
|
smtp_connection_pool: Arc<SmtpConnectionPool<S>>,
|
||||||
|
mxdeliv_unsupported_hosts: Arc<retainer::Cache<String, ()>>,
|
||||||
|
monitor_handle: JoinHandle<()>,
|
||||||
|
dns_resolver: Arc<TokioResolver>,
|
||||||
|
queue_size: usize,
|
||||||
|
capacity: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> WorkerPool<S>
|
||||||
|
where
|
||||||
|
S: TcpStreamTrait + TcpConnect,
|
||||||
|
S::ConnectionContext: Default,
|
||||||
|
{
|
||||||
|
pub fn new(config: Config) -> Result<Self, crate::error::Error> {
|
||||||
|
let dns_resolver = Arc::new(build_resolver()?);
|
||||||
|
|
||||||
|
let mxdeliv_cache = Arc::new(retainer::Cache::new());
|
||||||
|
let mxdeliv_cache_clone = mxdeliv_cache.clone();
|
||||||
|
|
||||||
|
let monitor_handle = tokio::spawn(async move {
|
||||||
|
mxdeliv_cache_clone
|
||||||
|
.monitor(4, 0.25, Duration::from_secs(10))
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
inner: Default::default(),
|
||||||
|
client_hostname: config.mail_domain,
|
||||||
|
dns_resolver,
|
||||||
|
smtp_connection_pool: SmtpConnectionPool::<S>::new(Default::default()),
|
||||||
|
mxdeliv_unsupported_hosts: mxdeliv_cache,
|
||||||
|
monitor_handle,
|
||||||
|
queue_size: PER_DESTINATION_QUEUE_SIZE,
|
||||||
|
capacity: MAX_WORKERS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as [`Self::new`], but lets you set the size of the queue and pool capacity.
|
||||||
|
///
|
||||||
|
/// Used only for tests.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn with_queue_size_and_pool_capacity(
|
||||||
|
config: Config,
|
||||||
|
queue_size: usize,
|
||||||
|
pool_capacity: usize,
|
||||||
|
) -> Result<Self, crate::error::Error> {
|
||||||
|
let mut this = Self::new(config)?;
|
||||||
|
this.queue_size = queue_size;
|
||||||
|
this.capacity = pool_capacity;
|
||||||
|
Ok(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets a worker for provided `destination`,
|
||||||
|
/// spawning a new one if required.
|
||||||
|
///
|
||||||
|
/// Returns [`None`], if operation requires spawning a new worker,
|
||||||
|
/// but pool already operates at maximum worker capacity.
|
||||||
|
fn get_or_create_worker(&self, destination: &AddressDomain) -> Option<Arc<Worker>> {
|
||||||
|
// NOTE: these locks are blocking, but critical section here is quite small and
|
||||||
|
// shouldn't cause issues in async code.
|
||||||
|
// NOTE: read() returns a guard that is dropped before the match statement.
|
||||||
|
// This must be ensured or else, the write() line would cause a deadlock.
|
||||||
|
let worker = {
|
||||||
|
let map = self.inner.read();
|
||||||
|
map.get(destination).cloned()
|
||||||
|
};
|
||||||
|
|
||||||
|
match worker {
|
||||||
|
Some(worker) => Some(worker),
|
||||||
|
None => {
|
||||||
|
// Worker for this destination wasn't spawned yet.
|
||||||
|
let mut map = self.inner.write();
|
||||||
|
|
||||||
|
// we check if it wasn't spawned in the meantime first
|
||||||
|
if let Some(worker) = map.get(destination)
|
||||||
|
&& !worker.handle.is_finished()
|
||||||
|
{
|
||||||
|
return Some(worker.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if map.len() >= self.capacity {
|
||||||
|
log::warn!(
|
||||||
|
"Worker pool operating at maximum capacity! \
|
||||||
|
Messages to new destinations will be deferred."
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel(self.queue_size);
|
||||||
|
let handle = tokio::spawn(Worker::run(
|
||||||
|
destination.clone(),
|
||||||
|
rx,
|
||||||
|
self.client_hostname.clone(),
|
||||||
|
self.smtp_connection_pool.clone(),
|
||||||
|
self.mxdeliv_unsupported_hosts.clone(),
|
||||||
|
self.dns_resolver.clone(),
|
||||||
|
self.inner.clone(),
|
||||||
|
));
|
||||||
|
log::trace!("Worker {} spawned", handle.id());
|
||||||
|
let worker = Arc::new(Worker { tx, handle });
|
||||||
|
|
||||||
|
map.insert(destination.clone(), worker.clone());
|
||||||
|
Some(worker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries to get an [`OwnedPermit`] to the worker for specified destination.
|
||||||
|
///
|
||||||
|
/// Returns [`None`] if:
|
||||||
|
///
|
||||||
|
/// - the worker's queue is full,
|
||||||
|
/// - operation requires spawning a new worker,
|
||||||
|
/// but pool already operates at maximum worker capacity.
|
||||||
|
pub fn get_permit(&self, destination: &AddressDomain) -> Option<OwnedPermit<WorkerMessage>> {
|
||||||
|
if let Some(worker) = self.get_or_create_worker(destination) {
|
||||||
|
return worker.tx.clone().try_reserve_owned().ok();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Informs pool that a worker has exited unexpectedly.
|
||||||
|
///
|
||||||
|
/// Such worker will be removed from the pool,
|
||||||
|
/// and re-created on the next [`Self::get_permit`] call.
|
||||||
|
pub fn cleanup(&self, destination: &AddressDomain) {
|
||||||
|
let mut map = self.inner.write();
|
||||||
|
// we first check in case other task already removed/re-created it
|
||||||
|
if let Some(worker) = map.get(destination)
|
||||||
|
&& worker.handle.is_finished()
|
||||||
|
{
|
||||||
|
log::info!(
|
||||||
|
"Removing a dead worker {} for destination {destination}",
|
||||||
|
worker.handle.id()
|
||||||
|
);
|
||||||
|
map.remove(destination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: TcpConnect> Drop for WorkerPool<S> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.monitor_handle.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Worker {
|
||||||
|
pub tx: mpsc::Sender<WorkerMessage>,
|
||||||
|
handle: JoinHandle<Result<(), crate::error::Error>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Worker {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.handle.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Worker {
|
||||||
|
pub async fn run<S>(
|
||||||
|
destination: AddressDomain,
|
||||||
|
mut rx: mpsc::Receiver<WorkerMessage>,
|
||||||
|
client_hostname: String,
|
||||||
|
smtp_connection_pool: Arc<SmtpConnectionPool<S>>,
|
||||||
|
mxdeliv_unsupported_hosts: Arc<retainer::Cache<String, ()>>,
|
||||||
|
dns_resolver: Arc<TokioResolver>,
|
||||||
|
worker_pool: Arc<RwLock<BTreeMap<AddressDomain, Arc<Worker>>>>,
|
||||||
|
) -> Result<(), crate::error::Error>
|
||||||
|
where
|
||||||
|
S: TcpStreamTrait + TcpConnect,
|
||||||
|
{
|
||||||
|
let worker_id = task::try_id()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.unwrap_or("?".to_string());
|
||||||
|
|
||||||
|
log::info!("Starting worker {worker_id} for destination {destination}");
|
||||||
|
|
||||||
|
let tls_resumption_store = Arc::new(rustls::client::ClientSessionMemoryCache::new(256));
|
||||||
|
let https_client = HttpsClient::new(tls_resumption_store.clone())?;
|
||||||
|
|
||||||
|
while let Ok(Some(message)) = timeout(WORKER_KEEPALIVE_DURATION, rx.recv()).await {
|
||||||
|
log::trace!(
|
||||||
|
"Worker {worker_id} received a message from {}",
|
||||||
|
message.envelope.mail_from
|
||||||
|
);
|
||||||
|
let result = Self::handle_single_domain(
|
||||||
|
tls_resumption_store.clone(),
|
||||||
|
smtp_connection_pool.clone(),
|
||||||
|
mxdeliv_unsupported_hosts.clone(),
|
||||||
|
https_client.clone(),
|
||||||
|
dns_resolver.clone(),
|
||||||
|
destination.clone(),
|
||||||
|
message.envelope,
|
||||||
|
client_hostname.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if message.response_tx.send(result).is_err() {
|
||||||
|
log::error!(
|
||||||
|
"Worker {worker_id} ({destination}) failed to send response to transport handler."
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Worker {worker_id} for domain {destination} shutting down...");
|
||||||
|
worker_pool.write().remove(&destination);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a single email transaction for a single recipient domain.
|
||||||
|
#[expect(clippy::too_many_arguments)]
|
||||||
|
async fn handle_single_domain<S>(
|
||||||
|
tls_resumption_store: Arc<rustls::client::ClientSessionMemoryCache>,
|
||||||
|
smtp_connection_pool: Arc<SmtpConnectionPool<S>>,
|
||||||
|
mxdeliv_unsupported_hosts: Arc<retainer::Cache<String, ()>>,
|
||||||
|
https_client: HttpsClient,
|
||||||
|
dns_resolver: Arc<TokioResolver>,
|
||||||
|
domain: AddressDomain,
|
||||||
|
envelope: Envelope,
|
||||||
|
client_hostname: String,
|
||||||
|
) -> Result<String, String>
|
||||||
|
where
|
||||||
|
S: TcpStreamTrait + TcpConnect,
|
||||||
|
{
|
||||||
|
let mut allow_invalid_cert = false;
|
||||||
|
let mut skip_tls = SMTP_SKIP_TLS; // only respected by smtp channel
|
||||||
|
|
||||||
|
let mx_hosts = match domain {
|
||||||
|
// no-DNS setup; assume the ip from email address is the destination.
|
||||||
|
AddressDomain::Literal(ip) => {
|
||||||
|
// We allow self-signed certs on IP-based relays.
|
||||||
|
allow_invalid_cert = true;
|
||||||
|
vec![(0, ip)]
|
||||||
|
}
|
||||||
|
AddressDomain::Name(mx_domain) => {
|
||||||
|
if mx_domain.eq_ignore_ascii_case("nauta.cu") {
|
||||||
|
// Special case; We don't want to defederate nauta.cu,
|
||||||
|
// which doesn't support STARTTLS at all.
|
||||||
|
skip_tls = true;
|
||||||
|
} else if mx_domain.starts_with('_') {
|
||||||
|
// We use domains starting with `_` for test deployments.
|
||||||
|
// (You can't request a non-wildcard cert for such domain)
|
||||||
|
allow_invalid_cert = true;
|
||||||
|
}
|
||||||
|
let query = format!("{mx_domain}.");
|
||||||
|
|
||||||
|
match dns_resolver.mx_lookup(query).await {
|
||||||
|
Ok(mx_records) => {
|
||||||
|
let mut hosts: Vec<(u16, String)> = Vec::new();
|
||||||
|
for mx_record in mx_records.answers() {
|
||||||
|
let mx = match mx_record.data {
|
||||||
|
RData::MX(ref mx) => mx,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Null MX / RFC7505
|
||||||
|
if mx.exchange.is_root() {
|
||||||
|
// From RFC7505 section 3:
|
||||||
|
// > A domain that advertises a null MX MUST NOT
|
||||||
|
// > advertise any other MX RR.
|
||||||
|
// We assume this is the only record and exit early.
|
||||||
|
return Err(
|
||||||
|
"556 5.1.10 Permanent failure: Recipient address has null MX"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = mx.exchange.to_string().trim_end_matches('.').to_string();
|
||||||
|
hosts.push((mx.preference, host))
|
||||||
|
}
|
||||||
|
hosts.sort();
|
||||||
|
hosts
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if e.is_no_records_found() {
|
||||||
|
// "implicit MX" as described by section 5.1 of RFC5321
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc5321#section-5.1
|
||||||
|
log::debug!("No MX record found, using implicit MX: {mx_domain}");
|
||||||
|
vec![(0, mx_domain)]
|
||||||
|
} else if e.is_nx_domain() {
|
||||||
|
return Err(format!("512 Domain {mx_domain} does not exist"));
|
||||||
|
} else {
|
||||||
|
return Err(format!("421 DNS resolution failed for {mx_domain}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tls_config = match skip_tls {
|
||||||
|
true => None,
|
||||||
|
false => Some(TlsConfig {
|
||||||
|
allow_invalid_cert,
|
||||||
|
session_cache: tls_resumption_store,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
|
// we try sequentially in order of MX preference,
|
||||||
|
// but the IPv4 and IPv6 connections (after `smtp_client::send` resolves mx hostname)
|
||||||
|
// happens in parallel.
|
||||||
|
'try_relay: for (_, mx_host) in mx_hosts {
|
||||||
|
let skip_mxdeliv = mxdeliv_unsupported_hosts
|
||||||
|
.get(&mx_host)
|
||||||
|
.await
|
||||||
|
.map(|guard| *guard.value())
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
// HTTPS channel
|
||||||
|
if skip_mxdeliv {
|
||||||
|
log::debug!("Skipping HTTP delivery to host that failed recently: {mx_host}");
|
||||||
|
} else {
|
||||||
|
match Self::https_delivery(
|
||||||
|
https_client.clone(),
|
||||||
|
mx_host.clone(),
|
||||||
|
&envelope,
|
||||||
|
allow_invalid_cert,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
return Ok(OK_HTTPS_250.to_string());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("HTTPS delivery to {mx_host} failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTP channel (fallback)
|
||||||
|
let client_config = crate::smtp_client::ClientConfig {
|
||||||
|
client_hostname: &client_hostname,
|
||||||
|
tls_config: tls_config.clone(),
|
||||||
|
lmtp: false,
|
||||||
|
};
|
||||||
|
match crate::smtp_client::send(
|
||||||
|
&mx_host,
|
||||||
|
SMTP_PORT,
|
||||||
|
&envelope,
|
||||||
|
client_config,
|
||||||
|
dns_resolver.clone(),
|
||||||
|
smtp_connection_pool.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
// Switches this host to SMTP for 30 minutes.
|
||||||
|
// Note: this MUST happen only after a successful SMTP delivery,
|
||||||
|
// or otherwise any http error will lock us out of any way to
|
||||||
|
// deliver to a relay with a blocked port 25 for 30 minutes.
|
||||||
|
mxdeliv_unsupported_hosts
|
||||||
|
.insert(mx_host.clone(), (), Duration::from_mins(30))
|
||||||
|
.await;
|
||||||
|
return Ok(OK_SMTP_250.to_string());
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
match &error {
|
||||||
|
// We only want to try other MX hosts if we encounter a problem
|
||||||
|
// related to connection.
|
||||||
|
// (So we don't spam other servers if the message is actually rejected.)
|
||||||
|
crate::error::Error::Io(_)
|
||||||
|
| crate::error::Error::ConnectionFailed(_)
|
||||||
|
| crate::error::Error::Tls(_) => {
|
||||||
|
// Make sure we quickly retry HTTP if SMTP failed to connect
|
||||||
|
mxdeliv_unsupported_hosts.remove(&mx_host).await;
|
||||||
|
log::warn!(
|
||||||
|
"Connection error relaying to mail server {mx_host}: {error}"
|
||||||
|
);
|
||||||
|
last_error = Some((error.smtp_response(), mx_host.clone()));
|
||||||
|
continue 'try_relay;
|
||||||
|
}
|
||||||
|
crate::error::Error::MailSend { .. } => {
|
||||||
|
log::warn!("Message rejected by mail server {mx_host}: {error}");
|
||||||
|
return Err(error.smtp_response());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
log::warn!(
|
||||||
|
"Unexpected error while delivering to mail server {mx_host}: {error}"
|
||||||
|
);
|
||||||
|
return Err(format!(
|
||||||
|
"{} (while attempting delivery to {mx_host})",
|
||||||
|
error.smtp_response()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (error, mx_host) = last_error.unwrap_or(("?".to_string(), "?".to_string()));
|
||||||
|
Err(format!(
|
||||||
|
"421 Failed to connect to any mail server; last attempt to {mx_host}: {error}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs mail delivery to `mx_host` over HTTPS.
|
||||||
|
///
|
||||||
|
/// Times out after 60s.
|
||||||
|
async fn https_delivery(
|
||||||
|
https_client: HttpsClient,
|
||||||
|
mx_host: String,
|
||||||
|
envelope: &Envelope,
|
||||||
|
allow_invalid_cert: bool,
|
||||||
|
) -> Result<(), crate::error::Error> {
|
||||||
|
let request: hyper::Request<http_body_util::Full<Bytes>> = {
|
||||||
|
let mut builder = hyper::Request::builder()
|
||||||
|
.method(hyper::Method::POST)
|
||||||
|
.uri(format!("https://{mx_host}/mxdeliv"));
|
||||||
|
|
||||||
|
if !envelope.mail_from.is_empty() {
|
||||||
|
builder = builder.header(HEADER_MAIL_FROM, &envelope.mail_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
for rcpt_to in &envelope.rcpt_to {
|
||||||
|
builder = builder.header(HEADER_RCPT_TO, rcpt_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.body(http_body_util::Full::from(envelope.data.clone()))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = if allow_invalid_cert {
|
||||||
|
https_client.relaxed
|
||||||
|
} else {
|
||||||
|
https_client.secure
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = tokio::time::timeout(Duration::from_secs(60), client.request(request))
|
||||||
|
.await
|
||||||
|
.map_err(|_| crate::error::Error::MailSend {
|
||||||
|
context: "HTTPS delivery".to_string(),
|
||||||
|
raw_smtp_answer: "[timeout]".to_string(),
|
||||||
|
host: mx_host.clone(),
|
||||||
|
})??;
|
||||||
|
if response.status().is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let response_body = response.collect().await?.to_bytes();
|
||||||
|
Err(crate::error::Error::MailSend {
|
||||||
|
context: "HTTPS delivery".to_string(),
|
||||||
|
raw_smtp_answer: String::from_utf8_lossy(&response_body).into(),
|
||||||
|
host: mx_host,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WorkerMessage {
|
||||||
|
pub envelope: Envelope,
|
||||||
|
pub response_tx: oneshot::Sender<SMTPResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerMessage {
|
||||||
|
pub fn new(envelope: Envelope) -> (Self, oneshot::Receiver<SMTPResponse>) {
|
||||||
|
let (response_tx, response_rx) = oneshot::channel();
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
envelope,
|
||||||
|
response_tx,
|
||||||
|
},
|
||||||
|
response_rx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
use hickory_resolver::{TokioResolver, proto::dnssec::TrustAnchors};
|
||||||
|
use mailparse::MailAddr;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Extracts the first email address found in SMTP command or email header.
|
||||||
|
///
|
||||||
|
/// Return `None` if parsing fails.
|
||||||
|
///
|
||||||
|
/// Returns the first address if multiple are present.
|
||||||
|
pub fn extract_address(input: &str) -> Option<String> {
|
||||||
|
let input_lower = input.to_lowercase();
|
||||||
|
let mut trimmed = input_lower
|
||||||
|
.trim_start_matches("mail from:")
|
||||||
|
.trim_start_matches("rcpt to:");
|
||||||
|
|
||||||
|
let addr_end = trimmed.find('>').unwrap_or(trimmed.len() - 1);
|
||||||
|
trimmed = trimmed
|
||||||
|
.split_at_checked(addr_end + 1)
|
||||||
|
.map(|(address_raw, _)| address_raw)
|
||||||
|
.unwrap_or(trimmed);
|
||||||
|
|
||||||
|
mailparse::addrparse(trimmed)
|
||||||
|
.ok()
|
||||||
|
.and_then(|addr| match addr.first() {
|
||||||
|
Some(MailAddr::Single(single)) => Some(single.addr.clone()),
|
||||||
|
Some(MailAddr::Group(group)) => group.addrs.first().map(|single| single.addr.clone()),
|
||||||
|
None => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Domain part of an email address, either a domain-literal (IP address in square brackets with
|
||||||
|
/// optional protocol prefix) or a regular domain name.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord)]
|
||||||
|
pub enum AddressDomain {
|
||||||
|
/// Domain literal, e.g.
|
||||||
|
/// - `192.0.2.0` in `test@[192.0.2.0]`,
|
||||||
|
/// - `2001:db8::1` in `test@[IPv6:2001:db8::1]`.
|
||||||
|
Literal(String),
|
||||||
|
/// Regular domain name, e.g. `example.org` in `test@example.org`.
|
||||||
|
Name(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for AddressDomain {
|
||||||
|
type Err = crate::error::Error;
|
||||||
|
|
||||||
|
/// Extracts the domain part from an email address and returns it as an [`AddressDomain`].
|
||||||
|
///
|
||||||
|
/// Returns an [`Error`] if `address` is not a valid email address.
|
||||||
|
///
|
||||||
|
/// [`Error`]: crate::error::Error
|
||||||
|
fn from_str(address: &str) -> Result<Self, Self::Err> {
|
||||||
|
let parts: Vec<&str> = address.split('@').collect();
|
||||||
|
if parts.len() == 2
|
||||||
|
&& let Some(domain) = parts.get(1)
|
||||||
|
{
|
||||||
|
// domain literals
|
||||||
|
if domain.starts_with('[') && domain.ends_with(']') {
|
||||||
|
let mut address_trimmed = domain
|
||||||
|
.get(1..domain.len() - 1)
|
||||||
|
.unwrap_or(domain)
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
address_trimmed = address_trimmed
|
||||||
|
.strip_prefix("ipv6:")
|
||||||
|
.unwrap_or(&address_trimmed)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
return Ok(AddressDomain::Literal(address_trimmed.to_string()));
|
||||||
|
}
|
||||||
|
Ok(AddressDomain::Name(domain.to_string()))
|
||||||
|
} else {
|
||||||
|
Err(crate::error::Error::InvalidEmailAddress(
|
||||||
|
address.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<str> for AddressDomain {
|
||||||
|
fn as_ref(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
AddressDomain::Literal(literal) => literal.as_ref(),
|
||||||
|
AddressDomain::Name(name) => name.as_ref(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for AddressDomain {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
AddressDomain::Literal(addr) => {
|
||||||
|
// naive check, but should be enough as long as `AddressDomain`
|
||||||
|
// is constructed using parse/from_str.
|
||||||
|
write!(f, "[")?;
|
||||||
|
if addr.contains(':') {
|
||||||
|
write!(f, "IPv6:")?;
|
||||||
|
}
|
||||||
|
write!(f, "{addr}]")?;
|
||||||
|
}
|
||||||
|
AddressDomain::Name(domain) => write!(f, "{domain}")?,
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logs email to `/tmp/filtermail-rejected/<reason>/<timestamp>.eml`
|
||||||
|
/// and returns the file path.
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::Error`] on IO error.
|
||||||
|
pub async fn log_eml(reason: &str, data: &[u8]) -> Result<PathBuf, crate::error::Error> {
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let filename = format!("{timestamp}.eml");
|
||||||
|
let mut path = PathBuf::from(format!("/tmp/filtermail-rejected/{reason}"));
|
||||||
|
tokio::fs::create_dir_all(&path).await?;
|
||||||
|
path.push(filename);
|
||||||
|
tokio::fs::write(&path, data).await?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a DNS resolver with DNSSEC enabled and system configuration (resolv.conf).
|
||||||
|
pub fn build_resolver() -> Result<TokioResolver, crate::error::Error> {
|
||||||
|
let mut builder = TokioResolver::builder_tokio()?
|
||||||
|
// https://github.com/hickory-dns/hickory-dns/issues/3519
|
||||||
|
.with_trust_anchor(Arc::new(TrustAnchors::default()));
|
||||||
|
|
||||||
|
// disable negative caching to prevent possible federation problems
|
||||||
|
builder.options_mut().negative_max_ttl = Some(Duration::ZERO);
|
||||||
|
|
||||||
|
let dns_resolver = builder.build()?;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
dns_resolver.options().validate,
|
||||||
|
"incorrect resolver config: DNSSEC disabled; exiting"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(dns_resolver)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rstest::*;
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case("MAIL FROM:<t1@example.org>", Some("t1@example.org".to_string()))]
|
||||||
|
#[case("MAIL FROM:<t2@example.org> SOMETHING=SOMETHING OTHER=OTHER", Some("t2@example.org".to_string()))]
|
||||||
|
#[case("MAIL FROM:<SRS1=HHH=example.com==HHH=TT=example.org=alice@example.net> abc=def", Some("srs1=hhh=example.com==hhh=tt=example.org=alice@example.net".to_string()))]
|
||||||
|
#[case("MAIL FROM:<abc+alice@example.net> abc=def", Some("abc+alice@example.net".to_string()))]
|
||||||
|
#[case("RCPT TO:<t3@example.org>", Some("t3@example.org".to_string()))]
|
||||||
|
#[case("mail from:<t4@example.org>", Some("t4@example.org".to_string()))]
|
||||||
|
#[case("Foo Bar <t5@example.org>", Some("t5@example.org".to_string()))]
|
||||||
|
#[case("t6@example.org", Some("t6@example.org".to_string()))]
|
||||||
|
#[case("t7@[192.0.2.0]", Some("t7@[192.0.2.0]".to_string()))]
|
||||||
|
#[case("<t7@[192.0.2.0]>", Some("t7@[192.0.2.0]".to_string()))]
|
||||||
|
// This is a bug in mailparse, it refuses to parse IPv6 without "<>" around.
|
||||||
|
// https://github.com/staktrace/mailparse/issues/137
|
||||||
|
#[case("t8@[IPv6:2001:db8::1]", None)]
|
||||||
|
#[case("<t8@[IPv6:2001:db8::1]>", Some("t8@[ipv6:2001:db8::1]".to_string()))]
|
||||||
|
fn test_extract_address(#[case] input: &str, #[case] expected: Option<String>) {
|
||||||
|
let result = extract_address(input);
|
||||||
|
assert_eq!(result, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case("t1@example.org", Some(AddressDomain::Name("example.org".to_string())))]
|
||||||
|
#[case("SRS1=HHH=example.com==HHH=TT=example.org=alice@example.net", Some(AddressDomain::Name("example.net".to_string())))]
|
||||||
|
#[case("t7@[192.0.2.0]", Some(AddressDomain::Literal("192.0.2.0".to_string())))]
|
||||||
|
#[case("t8@[IPv6:2001:db8::1]", Some(AddressDomain::Literal("2001:db8::1".to_string())))]
|
||||||
|
#[case("invalid", None)]
|
||||||
|
#[case("invalid@address@com", None)]
|
||||||
|
fn test_get_domain_from_address(#[case] input: &str, #[case] expected: Option<AddressDomain>) {
|
||||||
|
let result = AddressDomain::from_str(input).ok();
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case::domain(AddressDomain::Name("example.org".to_string()), "example.org")]
|
||||||
|
#[case::ipv4(AddressDomain::Literal("192.0.2.0".to_string()), "192.0.2.0")]
|
||||||
|
#[case::ipv6(AddressDomain::Literal("2001:db8::1".to_string()), "2001:db8::1")]
|
||||||
|
fn test_address_domain_as_ref(#[case] input: AddressDomain, #[case] expected: &str) {
|
||||||
|
assert_eq!(input.as_ref(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case::domain(AddressDomain::Name("example.org".to_string()), "example.org")]
|
||||||
|
#[case::ipv4(AddressDomain::Literal("192.0.2.0".to_string()), "[192.0.2.0]")]
|
||||||
|
#[case::ipv6(AddressDomain::Literal("2001:db8::1".to_string()), "[IPv6:2001:db8::1]")]
|
||||||
|
fn test_address_domain_display(#[case] input: AddressDomain, #[case] expected: &str) {
|
||||||
|
assert_eq!(&format!("{input}"), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
From: one@example.org
|
||||||
|
To: two@example.org
|
||||||
|
Autocrypt-Setup-Message: v1
|
||||||
|
Subject: Autocrypt Setup Message
|
||||||
|
Date: Tue, 22 Jan 2019 12:56:29 +0100
|
||||||
|
Content-type: multipart/mixed; boundary="Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ"
|
||||||
|
|
||||||
|
--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
This message contains all information to transfer your Autocrypt
|
||||||
|
settings along with your secret key securely from your original
|
||||||
|
device.
|
||||||
|
|
||||||
|
To set up your new device for Autocrypt, please follow the
|
||||||
|
instuctions that should be presented by your new device.
|
||||||
|
|
||||||
|
You can keep this message and use it as a backup for your secret
|
||||||
|
key. If you want to do this, you should write down the Setup Code
|
||||||
|
and store it securely.
|
||||||
|
--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ
|
||||||
|
Content-Type: application/autocrypt-setup
|
||||||
|
Content-Disposition: attachment; filename="autocrypt-setup-message.html"
|
||||||
|
|
||||||
|
<html><body>
|
||||||
|
<p>
|
||||||
|
This is the Autocrypt setup file used to transfer settings and
|
||||||
|
keys between clients. You can decrypt it using the Setup Code
|
||||||
|
presented on your old device, and then import the contained key
|
||||||
|
into your keyring.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<pre>
|
||||||
|
-----BEGIN PGP MESSAGE-----
|
||||||
|
Passphrase-Format: numeric9x4
|
||||||
|
Passphrase-Begin: 17
|
||||||
|
|
||||||
|
jA0EBwMCFAxADoCdzeX/0ukBlqI5+pfpKb751qd/7nLNbkpy3gVcaf1QwRPZYt40
|
||||||
|
Ynp08UqRQ2g48ZlnzHLSwlTGOPTuv2Jt8ka+pgZ45xzvJSG2gau03xP4VsC271kR
|
||||||
|
VmCjdb0Y6Rk96mAwfGzrkbaRQ9Z7fIoL866GOv6h9neiVIkp+JYlTV6ISD0ZQJ4Q
|
||||||
|
I6dOQkB/TWZyVjtiJDOQHdfNWliA6NtqaLq19wlu9L5xXjuNpY95KwR8EJXWe0+o
|
||||||
|
Y3d2U/KxOAkXKghP2Qg1GtlPVeGC5T4p03TGI6pzKT+kHX6Rrm9wK6sM9aTquMmF
|
||||||
|
Vok84Jg1DFnwivWC2RILR81rXi7k/+Y6MUbveFgJ9cQduqpxnmD7TjOblYu7M6zp
|
||||||
|
YGAUxh8DRKlIMn2QsA++DBYQ6ACZvwuY8qTDLkqPDo4WqM313dsMJbyGjDdVE7EM
|
||||||
|
PESS+RlABETpZXz8g/ycr6DIUNdlbPcmYlsBfHWDOuR2GFFTwmlv5slWS39dJv38
|
||||||
|
E0eIe1CwdxI801Se7t7dUUS/ZF8wb6GlmxOcqGbF8eko1Z0S64IAm7/h13MRQCxI
|
||||||
|
geQnHfGYVJ2FOimoCMEKwfa9x++RFTDW0u7spDC2uWvK/1viV8OfRppFhLr/kmKb
|
||||||
|
18lWXuAz80DAjUDUsVqEq2MvJBJGoCJUEyjuRsLkHYRM5jYk4v50LyyR0Om73nWF
|
||||||
|
nZBqmqNzdr7Xb9PHHdFhnEc0VvoYbrcM0RVYcEMW3YbmejM891j1d6Iv+/n/qND/
|
||||||
|
NdebGrfWJMmFLf/iEkzTZ3/v5inW9LpWoRc94ioCjJTaEo8Rib6ARRFaJVIsmNXi
|
||||||
|
YicFGO98D+zX+a2t9Yz6IpPajVslnOp6ScpmXgts/2XWD7oE+JgxSAqo/dLVsHgP
|
||||||
|
Ufo=
|
||||||
|
=pulM
|
||||||
|
-----END PGP MESSAGE-----
|
||||||
|
</pre></body></html>
|
||||||
|
--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ--
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=abjadiyah.xyz;
|
||||||
|
s=default; t=1771356412;
|
||||||
|
bh=EVfAHeUMDygbJe0SkMWJHjgXGjtiTLZnMQbyWqzsrCY=;
|
||||||
|
h=From:To:Subject:Date:From;
|
||||||
|
b=3GHd4aCp7sjNR7RfHzLUUQ0PLk4FM8WLlOO5d8BXvpa6oCjERP0vsP545EDo4lBFC
|
||||||
|
xg9dlSrhIwEWsrJDXSFStA7VU1WmWBVTzKoN8bZk5w/2HJf7BcB1BG2SQ0tyu2iEtl
|
||||||
|
sv21HCXLw1IAMd2IiZNPQRlk0PgrKDqFmgULbktIWkc917GNAWhJaVCQhk+YF6cfql
|
||||||
|
AKP3i8NPK53JY6vsMBO3YuYb/DcRGyvhUUJGofgG2+NwESA20ayI85EPiUzbzGpx0z
|
||||||
|
r5IsLwiMApO5W0svfzgU52VhtkAqVyi38ZvjYsCJlKXB885SRsHsojCCSIznvFSmNw
|
||||||
|
pWF4qKBvNrQpQ==
|
||||||
|
From: <deltatest@abjadiyah.xyz>
|
||||||
|
To: "hidden-recipients": ;
|
||||||
|
Subject: [...]
|
||||||
|
Date: Sat, 14 Feb 2026 21:13:38 +0000
|
||||||
|
X-Last-TLS-Session-Version: TLSv1.3
|
||||||
|
X-Spamd-Result: default: False [5.18 / 15.00];
|
||||||
|
MISSING_MID(2.50)[];
|
||||||
|
FORGED_RECIPIENTS(2.00)[m:,s:link2xt@testrun.org];
|
||||||
|
DATE_IN_PAST(1.00)[70];
|
||||||
|
DMARC_POLICY_ALLOW(-0.50)[abjadiyah.xyz,reject];
|
||||||
|
R_MISSING_CHARSET(0.50)[];
|
||||||
|
R_DKIM_ALLOW(-0.20)[abjadiyah.xyz:s=default];
|
||||||
|
R_SPF_ALLOW(-0.20)[+mx];
|
||||||
|
MIME_GOOD(-0.10)[text/plain];
|
||||||
|
FISHY_TLD(0.10)[abjadiyah.xyz];
|
||||||
|
ONCE_RECEIVED(0.10)[];
|
||||||
|
IP_REPUTATION_HAM(-0.01)[asn: 29670(0.00), country: DE(-0.01), ip: 2001:67c:1400:21d0::(0.00)];
|
||||||
|
MX_GOOD(-0.01)[];
|
||||||
|
MISSING_XM_UA(0.00)[];
|
||||||
|
RCPT_COUNT_ONE(0.00)[1];
|
||||||
|
BCC(0.00)[];
|
||||||
|
ARC_NA(0.00)[];
|
||||||
|
MIME_TRACE(0.00)[0:+];
|
||||||
|
DWL_DNSWL_BLOCKED(0.00)[abjadiyah.xyz:dkim];
|
||||||
|
DKIM_TRACE(0.00)[abjadiyah.xyz:+];
|
||||||
|
RCPT_MAILCOW_DOMAIN(0.00)[testrun.org];
|
||||||
|
SINGLE_SHORT_PART(0.00)[];
|
||||||
|
ARC_SIGNED(0.00)[testrun.org:s=dkim:i=1];
|
||||||
|
RCVD_COUNT_ZERO(0.00)[0];
|
||||||
|
TO_DN_ALL(0.00)[];
|
||||||
|
ASN(0.00)[asn:29670, ipnet:2001:67c:1400::/45, country:DE];
|
||||||
|
FROM_NO_DN(0.00)[];
|
||||||
|
FROM_EQ_ENVFROM(0.00)[]
|
||||||
|
X-Rspamd-Queue-Id: E471A4105C
|
||||||
|
|
||||||
|
Hello!
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
Received: from chat.privittytech.com (unknown [35.154.144.0])
|
||||||
|
by nine.testrun.org (Postfix) with ESMTPS
|
||||||
|
for <i2kaq7knq@nine.testrun.org>; Sat, 21 Feb 2026 22:17:25 +0100 (CET)
|
||||||
|
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple;
|
||||||
|
d=chat.privittytech.com; s=opendkim; t=1771708644;
|
||||||
|
bh=Qs0Xoe6dH8cM3t3hv45QSkQQe5TLvyJ5R4OGJL9GYUc=;
|
||||||
|
h=Content-Type:From:To:Subject:Date:References:from:reply-to:
|
||||||
|
subject:date:to:cc:resent-date:resent-from:resent-sender:resent-to:
|
||||||
|
resent-cc:in-reply-to:references:list-id:list-help:
|
||||||
|
list-unsubscribe:list-subscribe:list-post:list-owner:list-archive:
|
||||||
|
autocrypt;
|
||||||
|
b=rsdW71iU79CF0dooc1PnbZsJbWZsnBampamyF3jTWirF8AXBBKeR2JJsxr4km3Vx9
|
||||||
|
QoT1s7VAyl/hdUBh2tBWT7Kzvw4KJcgkZ7tV/uRcY7edlLoH8TqATeTkWeNX18rL92
|
||||||
|
F6Vv/6krs/z8Rpz2ZaVkBdLQif0fLQ3e96ODOdpgkLFXWsMVTM5rGepPWYht8yA+BF
|
||||||
|
/sMopzgHhJaBvDqV42Ep75IK4w3opf9fPTAvtpwKbnDmJQmLSDv1So/Rd4xL3Oj6PX
|
||||||
|
CSO8dfJSzQ+ukyoDqVMddVCUpgVgFHTXMdxxQVTe54tj2JPZan5ql90SHa3lNFTgN8
|
||||||
|
hrM8BExznme5Q==
|
||||||
|
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
|
||||||
|
boundary="18965f9fc8f94f5b_d3ca8c34f6c5d05_4df177c93ae6931"
|
||||||
|
MIME-Version: 1.0
|
||||||
|
From: <8dn75miab@chat.privittytech.com>
|
||||||
|
To: "hidden-recipients": ;
|
||||||
|
Subject: [...]
|
||||||
|
Date: Mon, 16 Feb 2026 06:28:47 +0000
|
||||||
|
Message-ID: <0737aaa0-d6f9-4240-9d94-1b73216ca96d@localhost>
|
||||||
|
References: <0737aaa0-d6f9-4240-9d94-1b73216ca96d@localhost>
|
||||||
|
Chat-Version: 1.0
|
||||||
|
|
||||||
|
|
||||||
|
--18965f9fc8f94f5b_d3ca8c34f6c5d05_4df177c93ae6931
|
||||||
|
Content-Type: application/pgp-encrypted; charset="utf-8"
|
||||||
|
Content-Description: PGP/MIME version identification
|
||||||
|
Content-Transfer-Encoding: 7bit
|
||||||
|
|
||||||
|
Version: 1
|
||||||
|
|
||||||
|
--18965f9fc8f94f5b_d3ca8c34f6c5d05_4df177c93ae6931
|
||||||
|
Content-Type: application/octet-stream; name="encrypted.asc";
|
||||||
|
charset="utf-8"
|
||||||
|
Content-Description: OpenPGP encrypted message
|
||||||
|
Content-Disposition: inline; filename="encrypted.asc";
|
||||||
|
Content-Transfer-Encoding: 7bit
|
||||||
|
|
||||||
|
-----BEGIN PGP MESSAGE-----
|
||||||
|
|
||||||
|
wU4DWvqoogTPq7gSAQdAO8msB6rJLzr5isiWx/ZoAHqNnYZTcE4fpjpj8C1+Vhgg
|
||||||
|
c3o2P2p9T2GfJ6aa6KqbJQW7VPl8zaaylYCN8JQLKqHBTgPP/P3FoTw8jxIBB0CE
|
||||||
|
/iffxyAThaUNNQyp0vmrW9k8v47ll3TBQjCv6K/bbiB7u1vsYbBfl8FQgjDNhHTV
|
||||||
|
U+lJQntsqmsn3VmxRjz1vdLD4QGvqJXYHUUyYJ7EetMBHFcmpCLlO14MhvZKw4Gd
|
||||||
|
yWhEcgv6fsFpBaTk7Y9MXrflbpSR84aCtNqEKWU6FjjFWoRUzWVV42gI0cXectg6
|
||||||
|
Ry2p21EfiBsLLaQdMneep7Hpq/GXccLZcFVVc9VCQq80lq/9GkiDSdA/9JD3gGFu
|
||||||
|
NCDMKkgAQRaKVkfGZ7VhQqpkAZeeI3H/kGjzFElcNUqTH1lRgi7OgJuhIA9VeIWb
|
||||||
|
ZX677i3tjnNWrAJoIVs+l5I7jWhgE9VMutI4kBA0fxk2QYW++n9TPz2/FbTuFkjZ
|
||||||
|
ULXQC8F7CaVdUVb1DIAc7hw8EGFX98LKj04ucBuoe1QfJK6LKbhelSmGYGQbBOOG
|
||||||
|
AZSPs/jgH1EkgtIzxD6rTJTFy/+2Y4eOTJaq0F4ZP32+pOVr3Iq2OuSS3LakfCjk
|
||||||
|
TPqe9Y1w4q0afQdS2tHlnLGOGrFxoYeRjUnTeeHuH9sSp6G6Lf5751sLVgYUtILo
|
||||||
|
Kgaac+gCh0rBK4Ve4LksnY6FUzogLYxIt4NkmDIWDvlDc0RnC+moQ/VaICPYlnMl
|
||||||
|
dJB4mVFKefqqQM61rWTZyqHc8tDJj0D1dqpxkYJT+VH+t0RNp6br8I+wQ91BP595
|
||||||
|
Usma3Uv1MjLufMFWcTtd72PAfk13uWF2u2dkKO+KR4MUnXm/B3ZSBAUfGfBaH4SP
|
||||||
|
mn/dDeNY0c9ya+8l+Nt2lK/3imh3E29z22pnVwTB/PAK1HCxrehw6j/W6cfrylXC
|
||||||
|
TcqLJW2K542oy5Q6zE5sU6mYRfOQcoR/s3t7y5ynu3s29kIbUhysPN2qT7YMmTWo
|
||||||
|
hSlMHU5oDrzDNKWkE1FsXpTCGTdSvKhQf/HuzmQBXiCQxLlH+55UJPwbf2H95o1A
|
||||||
|
cVChQP6vwqBIrB0k1CkJbfqCMPvr7fyNcZNqdk+iyXwPu9vOhE+4k5ybCZ7t/uoY
|
||||||
|
DHm05/nj5jqIEG2vV0sTogd8dnu6KiVK+wET4mZwOMmAPp/8ROOmZ2NvEK1iPlgL
|
||||||
|
c92u2XLAgZW+AbI93oJARWI8MGT5CfvJcKvjO1Nr4KIjN2G4uMiE//f2hPM676mq
|
||||||
|
eeFqFgITGN5ivfwnfdRgEHB63mkNeTVpzrHLNYYH/HVPdjsTqDp0cPCvZxoIDgTj
|
||||||
|
/9+uJtfgRekpD0LqOfnUg27TBthFksuFaiMW4cvxgNdqOVbo7RjcSvGg2pd80Bu0
|
||||||
|
0gv/47tDGl0Hn9BI+XIbwLRos6dSzxgU6XotjfA31lcSFiP99IkWH4sBFRWVIaEX
|
||||||
|
iPqJOmDWsgWjzzgu9JYQ8cZH4BP74Rv8qCC2klP4HfdQuErekNu7vDPLH3iHWpuX
|
||||||
|
HcSc6imuNXz45ncwyEsQ+tP4t42SEv0dGRaMpwNIniIiKYAL+dMFNvdqEsKiPRoG
|
||||||
|
f7xEuY2sZKoBtNw+gXcGlYs+QhSXJtpAdpHg+Bp0AYUQBU9ciYFfk8ZsEbSdpWGP
|
||||||
|
D0+Xd7bWrgUYN0UM0EdtpbyxnH/bFe3Jg5jdDuS43ifhYTEPEdpmG5pnqNk5Yi0Q
|
||||||
|
wxN6ntEHbfy3gmhMePOigh09RlwgbdoqurlvlUL2FHEY/Z1P9VI0tHbTXayUcBQk
|
||||||
|
3aljbg==
|
||||||
|
=wRZT
|
||||||
|
-----END PGP MESSAGE-----
|
||||||
|
|
||||||
|
|
||||||
|
--18965f9fc8f94f5b_d3ca8c34f6c5d05_4df177c93ae6931--
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
From: one@[192.0.2.0]
|
||||||
|
To: two@example.org
|
||||||
|
Subject: ...
|
||||||
|
Date: Sun, 15 Oct 2023 16:43:21 +0000
|
||||||
|
Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>
|
||||||
|
In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||||
|
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||||
|
<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||||
|
Chat-Version: 1.0
|
||||||
|
Autocrypt: addr=one@example.org; prefer-encrypt=mutual;
|
||||||
|
keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH
|
||||||
|
rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID
|
||||||
|
FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW
|
||||||
|
XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK
|
||||||
|
KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ
|
||||||
|
JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP
|
||||||
|
IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
|
||||||
|
boundary="YFrteb74qSXmggbOxZL9dRnhymywAi"
|
||||||
|
|
||||||
|
|
||||||
|
--YFrteb74qSXmggbOxZL9dRnhymywAi
|
||||||
|
Content-Description: PGP/MIME version identification
|
||||||
|
Content-Type: application/pgp-encrypted
|
||||||
|
|
||||||
|
Version: 1
|
||||||
|
|
||||||
|
|
||||||
|
--YFrteb74qSXmggbOxZL9dRnhymywAi
|
||||||
|
Content-Description: OpenPGP encrypted message
|
||||||
|
Content-Disposition: inline; filename="encrypted.asc";
|
||||||
|
Content-Type: application/octet-stream; name="encrypted.asc"
|
||||||
|
|
||||||
|
-----BEGIN PGP MESSAGE-----
|
||||||
|
|
||||||
|
wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg
|
||||||
|
O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae
|
||||||
|
8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI
|
||||||
|
JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no
|
||||||
|
lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz
|
||||||
|
ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM
|
||||||
|
YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA
|
||||||
|
kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI
|
||||||
|
+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg
|
||||||
|
RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo
|
||||||
|
tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7
|
||||||
|
rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp
|
||||||
|
H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI
|
||||||
|
fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9
|
||||||
|
61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN
|
||||||
|
XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3
|
||||||
|
w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb
|
||||||
|
NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs
|
||||||
|
baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW
|
||||||
|
A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8
|
||||||
|
uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI
|
||||||
|
E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn
|
||||||
|
lkOWnEbCD+XTnbDd
|
||||||
|
=agR5
|
||||||
|
-----END PGP MESSAGE-----
|
||||||
|
|
||||||
|
|
||||||
|
--YFrteb74qSXmggbOxZL9dRnhymywAi--
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
From: one@[IPv6:2001:db8::1]
|
||||||
|
To: two@example.org
|
||||||
|
Subject: ...
|
||||||
|
Date: Sun, 15 Oct 2023 16:43:21 +0000
|
||||||
|
Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>
|
||||||
|
In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||||
|
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||||
|
<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||||
|
Chat-Version: 1.0
|
||||||
|
Autocrypt: addr=one@example.org; prefer-encrypt=mutual;
|
||||||
|
keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH
|
||||||
|
rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID
|
||||||
|
FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW
|
||||||
|
XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK
|
||||||
|
KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ
|
||||||
|
JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP
|
||||||
|
IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
|
||||||
|
boundary="YFrteb74qSXmggbOxZL9dRnhymywAi"
|
||||||
|
|
||||||
|
|
||||||
|
--YFrteb74qSXmggbOxZL9dRnhymywAi
|
||||||
|
Content-Description: PGP/MIME version identification
|
||||||
|
Content-Type: application/pgp-encrypted
|
||||||
|
|
||||||
|
Version: 1
|
||||||
|
|
||||||
|
|
||||||
|
--YFrteb74qSXmggbOxZL9dRnhymywAi
|
||||||
|
Content-Description: OpenPGP encrypted message
|
||||||
|
Content-Disposition: inline; filename="encrypted.asc";
|
||||||
|
Content-Type: application/octet-stream; name="encrypted.asc"
|
||||||
|
|
||||||
|
-----BEGIN PGP MESSAGE-----
|
||||||
|
|
||||||
|
wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg
|
||||||
|
O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae
|
||||||
|
8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI
|
||||||
|
JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no
|
||||||
|
lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz
|
||||||
|
ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM
|
||||||
|
YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA
|
||||||
|
kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI
|
||||||
|
+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg
|
||||||
|
RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo
|
||||||
|
tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7
|
||||||
|
rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp
|
||||||
|
H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI
|
||||||
|
fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9
|
||||||
|
61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN
|
||||||
|
XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3
|
||||||
|
w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb
|
||||||
|
NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs
|
||||||
|
baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW
|
||||||
|
A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8
|
||||||
|
uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI
|
||||||
|
E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn
|
||||||
|
lkOWnEbCD+XTnbDd
|
||||||
|
=agR5
|
||||||
|
-----END PGP MESSAGE-----
|
||||||
|
|
||||||
|
|
||||||
|
--YFrteb74qSXmggbOxZL9dRnhymywAi--
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
From: one@example.org
|
||||||
|
To: two@example.org
|
||||||
|
Subject: {subject}
|
||||||
|
Date: Sun, 15 Oct 2023 16:43:21 +0000
|
||||||
|
Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>
|
||||||
|
In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||||
|
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||||
|
<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||||
|
Chat-Version: 1.0
|
||||||
|
Autocrypt: addr=one@example.org; prefer-encrypt=mutual;
|
||||||
|
keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH
|
||||||
|
rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID
|
||||||
|
FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW
|
||||||
|
XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK
|
||||||
|
KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ
|
||||||
|
JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP
|
||||||
|
IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
|
||||||
|
boundary="YFrteb74qSXmggbOxZL9dRnhymywAi"
|
||||||
|
|
||||||
|
|
||||||
|
--YFrteb74qSXmggbOxZL9dRnhymywAi
|
||||||
|
Content-Description: PGP/MIME version identification
|
||||||
|
Content-Type: application/pgp-encrypted
|
||||||
|
|
||||||
|
Version: 1
|
||||||
|
|
||||||
|
|
||||||
|
--YFrteb74qSXmggbOxZL9dRnhymywAi
|
||||||
|
Content-Description: OpenPGP encrypted message
|
||||||
|
Content-Disposition: inline; filename="encrypted.asc";
|
||||||
|
Content-Type: application/octet-stream; name="encrypted.asc"
|
||||||
|
|
||||||
|
-----BEGIN PGP MESSAGE-----
|
||||||
|
|
||||||
|
wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg
|
||||||
|
O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae
|
||||||
|
8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI
|
||||||
|
JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no
|
||||||
|
lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz
|
||||||
|
ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM
|
||||||
|
YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA
|
||||||
|
kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI
|
||||||
|
+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg
|
||||||
|
RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo
|
||||||
|
tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7
|
||||||
|
rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp
|
||||||
|
H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI
|
||||||
|
fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9
|
||||||
|
61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN
|
||||||
|
XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3
|
||||||
|
w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb
|
||||||
|
NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs
|
||||||
|
baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW
|
||||||
|
A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8
|
||||||
|
uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI
|
||||||
|
E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn
|
||||||
|
lkOWnEbCD+XTnbDd
|
||||||
|
=agR5
|
||||||
|
-----END PGP MESSAGE-----
|
||||||
|
|
||||||
|
|
||||||
|
--YFrteb74qSXmggbOxZL9dRnhymywAi--
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=
|
||||||
|
Chat-Disposition-Notification-To: foobar@c2.testrun.org
|
||||||
|
Chat-User-Avatar: 0
|
||||||
|
From: <one@example.org>
|
||||||
|
To: <two@example.org>
|
||||||
|
Date: Sun, 15 Oct 2023 16:41:44 +0000
|
||||||
|
Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||||
|
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||||
|
Chat-Version: 1.0
|
||||||
|
Autocrypt: addr=one@example.org; prefer-encrypt=mutual;
|
||||||
|
keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m
|
||||||
|
nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID
|
||||||
|
FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I
|
||||||
|
HjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK
|
||||||
|
KwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ
|
||||||
|
JlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI
|
||||||
|
vYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
|
||||||
|
|
||||||
|
-----BEGIN PGP MESSAGE-----
|
||||||
|
Meow!
|
||||||
|
-----END PGP MESSAGE-----
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
From: one@example.org
|
||||||
|
To: two@example.org
|
||||||
|
Subject: ...
|
||||||
|
Date: Sun, 15 Oct 2023 16:43:21 +0000
|
||||||
|
Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>
|
||||||
|
In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||||
|
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||||
|
<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||||
|
Chat-Version: 1.0
|
||||||
|
Autocrypt: addr=one@example.org; prefer-encrypt=mutual;
|
||||||
|
keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH
|
||||||
|
rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID
|
||||||
|
FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW
|
||||||
|
XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK
|
||||||
|
KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ
|
||||||
|
JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP
|
||||||
|
IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
|
||||||
|
boundary="YFrteb74qSXmggbOxZL9dRnhymywAi"
|
||||||
|
|
||||||
|
|
||||||
|
--YFrteb74qSXmggbOxZL9dRnhymywAi
|
||||||
|
Content-Description: PGP/MIME version identification
|
||||||
|
Content-Type: application/pgp-encrypted
|
||||||
|
|
||||||
|
Version: 1
|
||||||
|
|
||||||
|
|
||||||
|
--YFrteb74qSXmggbOxZL9dRnhymywAi
|
||||||
|
Content-Description: OpenPGP encrypted message
|
||||||
|
Content-Disposition: inline; filename="encrypted.asc";
|
||||||
|
Content-Type: application/octet-stream; name="encrypted.asc"
|
||||||
|
|
||||||
|
-----BEGIN PGP MESSAGE-----
|
||||||
|
|
||||||
|
yxJiAAAAAABIZWxsbyB3b3JsZCE=
|
||||||
|
=1I/B
|
||||||
|
-----END PGP MESSAGE-----
|
||||||
|
|
||||||
|
|
||||||
|
--YFrteb74qSXmggbOxZL9dRnhymywAi--
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
Date: Fri, 8 Jul 1994 09:21:47 -0400
|
||||||
|
From: Mail Delivery Subsystem <MAILER-DAEMON@example.org>
|
||||||
|
Subject: Returned mail: User unknown
|
||||||
|
To: <owner-ups-mib@CS.UTK.EDU>
|
||||||
|
Auto-Submitted: auto-replied
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/report; report-type=delivery-status;
|
||||||
|
boundary="JAA13167.773673707/CS.UTK.EDU"
|
||||||
|
|
||||||
|
--JAA13167.773673707/CS.UTK.EDU
|
||||||
|
content-type: text/plain; charset=us-ascii
|
||||||
|
|
||||||
|
----- The following addresses had delivery problems -----
|
||||||
|
<arathib@vnet.ibm.com> (unrecoverable error)
|
||||||
|
<wsnell@sdcc13.ucsd.edu> (unrecoverable error)
|
||||||
|
|
||||||
|
--JAA13167.773673707/CS.UTK.EDU
|
||||||
|
content-type: message/delivery-status
|
||||||
|
|
||||||
|
Reporting-MTA: dns; cs.utk.edu
|
||||||
|
|
||||||
|
Original-Recipient: rfc822;arathib@vnet.ibm.com
|
||||||
|
Final-Recipient: rfc822;arathib@vnet.ibm.com
|
||||||
|
Action: failed
|
||||||
|
Status: 5.0.0 (permanent failure)
|
||||||
|
Diagnostic-Code: smtp;
|
||||||
|
550 'arathib@vnet.IBM.COM' is not a registered gateway user
|
||||||
|
Remote-MTA: dns; vnet.ibm.com
|
||||||
|
|
||||||
|
Original-Recipient: rfc822;johnh@hpnjld.njd.hp.com
|
||||||
|
Final-Recipient: rfc822;johnh@hpnjld.njd.hp.com
|
||||||
|
Action: delayed
|
||||||
|
Status: 4.0.0 (hpnjld.njd.jp.com: host name lookup failure)
|
||||||
|
|
||||||
|
Original-Recipient: rfc822;wsnell@sdcc13.ucsd.edu
|
||||||
|
Final-Recipient: rfc822;wsnell@sdcc13.ucsd.edu
|
||||||
|
Action: failed
|
||||||
|
Status: 5.0.0
|
||||||
|
Diagnostic-Code: smtp; 550 user unknown
|
||||||
|
Remote-MTA: dns; sdcc13.ucsd.edu
|
||||||
|
|
||||||
|
--JAA13167.773673707/CS.UTK.EDU
|
||||||
|
content-type: message/rfc822
|
||||||
|
|
||||||
|
[original message goes here]
|
||||||
|
--JAA13167.773673707/CS.UTK.EDU--
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
Subject: Message opened
|
||||||
|
From: <one@example.org>
|
||||||
|
To: <two@example.org>
|
||||||
|
Date: Sun, 15 Oct 2023 16:43:25 +0000
|
||||||
|
Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org>
|
||||||
|
Auto-Submitted: auto-replied
|
||||||
|
Chat-Version: 1.0
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/report; report-type=disposition-notification;
|
||||||
|
boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi"
|
||||||
|
|
||||||
|
|
||||||
|
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi
|
||||||
|
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
|
||||||
|
|
||||||
|
The "Hi!" message you sent was displayed on the screen of the recipient.
|
||||||
|
|
||||||
|
This is no guarantee the content was read.
|
||||||
|
|
||||||
|
|
||||||
|
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi
|
||||||
|
Content-Type: message/disposition-notification
|
||||||
|
|
||||||
|
Reporting-UA: Delta Chat 1.124.1
|
||||||
|
Original-Recipient: rfc822;barbaz@c2.testrun.org
|
||||||
|
Final-Recipient: rfc822;barbaz@c2.testrun.org
|
||||||
|
Original-Message-ID: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||||
|
Disposition: manual-action/MDN-sent-automatically; displayed
|
||||||
|
|
||||||
|
|
||||||
|
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi--
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=
|
||||||
|
Chat-Disposition-Notification-To: foobar@c2.testrun.org
|
||||||
|
Chat-User-Avatar: 0
|
||||||
|
From: <one@example.org>
|
||||||
|
To: <two@example.org>
|
||||||
|
Date: Sun, 15 Oct 2023 16:41:44 +0000
|
||||||
|
Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||||
|
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||||
|
Chat-Version: 1.0
|
||||||
|
Autocrypt: addr=one@example.org; prefer-encrypt=mutual;
|
||||||
|
keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m
|
||||||
|
nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID
|
||||||
|
FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I
|
||||||
|
HjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK
|
||||||
|
KwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ
|
||||||
|
JlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI
|
||||||
|
vYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
|
||||||
|
|
||||||
|
Meow!
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
Subject: Message from one@example.org
|
||||||
|
From: <one@example.org>
|
||||||
|
To: <two@example.org>
|
||||||
|
Date: Sun, 15 Oct 2023 16:43:25 +0000
|
||||||
|
Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org>
|
||||||
|
Chat-Version: 1.0
|
||||||
|
Secure-Join: vc-request
|
||||||
|
Secure-Join-Invitenumber: RANDOM-TOKEN
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/mixed; boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi"
|
||||||
|
|
||||||
|
|
||||||
|
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
Meow!
|
||||||
|
|
||||||
|
|
||||||
|
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi--
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
Subject: Message from one@example.org
|
||||||
|
From: <one@example.org>
|
||||||
|
To: <two@example.org>
|
||||||
|
Date: Sun, 15 Oct 2023 16:43:25 +0000
|
||||||
|
Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org>
|
||||||
|
Chat-Version: 1.0
|
||||||
|
Secure-Join: vc-request
|
||||||
|
Secure-Join-Invitenumber: RANDOM-TOKEN
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/mixed; boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi"
|
||||||
|
|
||||||
|
|
||||||
|
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
Secure-Join: vc-request
|
||||||
|
|
||||||
|
|
||||||
|
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi--
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user