mirror of
https://github.com/chatmail/relay.git
synced 2026-08-10 18:40:51 +00:00
Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c82412dc4c | |||
| 827bd5fc4f | |||
| 504fb249a9 | |||
| 6b3d475c82 | |||
| a0fe9c4b2c | |||
| deca1ef2a3 | |||
| 0323e757a9 | |||
| 752957dae8 | |||
| e47740f45f | |||
| 678bf2a684 | |||
| 9613b2f14f | |||
| 2bba729b6b | |||
| df6c9d777d | |||
| 3a553f2286 | |||
| 88e2124d59 | |||
| 5717940ac1 | |||
| ed5dfbe0ff | |||
| 5cdae22d31 | |||
| d00d2b870b | |||
| 7f14693b31 | |||
| 0fce0a2191 | |||
| 4d7bcf51b7 | |||
| 222bf36712 | |||
| d5ac67ecff | |||
| ac9df36801 | |||
| 03ca4a010b | |||
| db86cfc6ed | |||
| c7452526fa | |||
| 7ea7904ea0 | |||
| 7d0db2c46e | |||
| cb1e4ff5bb | |||
| 8d7870db55 | |||
| b36856a383 | |||
| a66887b778 | |||
| 488bbcb182 | |||
| 5a31ff475f | |||
| 8c18aea18e | |||
| ebf5a51964 | |||
| f596d4b56d | |||
| 8e3c18019b | |||
| 81aac3cb77 | |||
| 9da3f5c235 | |||
| d3f5412420 | |||
| 73b684f90c | |||
| fb5a25939c | |||
| 939e5367c9 | |||
| b0214a71d7 | |||
| 6def189d16 | |||
| 24612e9121 | |||
| 2538caa1b2 | |||
| f82f34d054 | |||
| a9dd9fe3e0 | |||
| aa846c3478 | |||
| 921080125f | |||
| 8a60245183 | |||
| d898f41064 | |||
| e9e012234b | |||
| 6d6517f8f3 | |||
| e9a63222e6 | |||
| bb40c5bb21 | |||
| a229f1bc45 | |||
| 893ceab107 | |||
| a6361a2194 | |||
| 4ba19b0031 | |||
| 5eab3a5a25 | |||
| 30729d9be0 | |||
| febfb90510 | |||
| c84a96ac78 | |||
| 847e19546b | |||
| 664ad571b6 | |||
| 91d09ab370 | |||
| e2be5dc92d | |||
| d74e5b85b3 | |||
| b7a213ff1b | |||
| 3896044f36 | |||
| e26cca0260 | |||
| 272744115f | |||
| a65e7a1490 | |||
| 36921dd29f | |||
| 5295d175d6 | |||
| 10119033a3 | |||
| d29cbafda4 | |||
| bc769e0d2a | |||
| 59944a46ff | |||
| 457461b4ff | |||
| 94dcd6c728 | |||
| 36ff21ff91 | |||
| 5b79d2d08a | |||
| 0a652e61ee | |||
| 1b1585eb7d | |||
| 21aaea0d92 | |||
| c1a4d99d2e | |||
| 17ccd3e2ca | |||
| 3eb3b86bc9 | |||
| 1e356315da | |||
| 0e1b7173ab | |||
| e156ce9125 | |||
| dee00ebdcf | |||
| 45eaa2f659 | |||
| cf64778f71 | |||
| ec0413bd5f | |||
| 2450d1d9c7 | |||
| 0feb1371b3 | |||
| 8ad65923e9 | |||
| 5575df7510 | |||
| 0f58942998 | |||
| 05cbf2908c | |||
| 621eaeeffb | |||
| ca3393cf48 | |||
| a4497bc10d | |||
| fb77c5be81 | |||
| 7da0c34678 | |||
| 9a4a54cea8 | |||
| 2aa894f48f | |||
| 23b28bc66e | |||
| b47b859fcb | |||
| 49df79bfa0 | |||
| b31e13a417 | |||
| 8dd9436a0d | |||
| 82a9850a4b | |||
| 779e7ca2b4 | |||
| 0d7f5d7b05 | |||
| 07496abab4 | |||
| a8033b107e | |||
| c088cbfe4d | |||
| 3a571b7aa7 | |||
| 962de095c0 | |||
| 954d950c2f | |||
| f149797775 | |||
| 3054b31dc7 | |||
| edca7cbf2a | |||
| 6bb90f8811 | |||
| e62b161848 | |||
| 82618de311 | |||
| a5c6880ceb | |||
| 2e1c38466c | |||
| ff3ffd54ef | |||
| e1841cb590 | |||
| 52208c42b6 | |||
| 8a1684d488 | |||
| 38627bfb4b | |||
| 6145be4108 | |||
| 288afa3273 | |||
| 54982b1fdd | |||
| 94f1b29917 | |||
| 7d4fac698f | |||
| 083ec1a60b | |||
| 6e2a37956c | |||
| 4eb7e9fd63 | |||
| 4837754245 | |||
| a096d0550f | |||
| 3bf8395ef6 | |||
| 85d6c381b3 | |||
| 21f0614b5d | |||
| 10fd148155 | |||
| cc1604b0b2 | |||
| c1f728535f | |||
| 0ee1e9924c | |||
| 8d18b73681 | |||
| 47f16360a4 | |||
| 7be2004051 | |||
| 95fd106e43 | |||
| 23231d462c | |||
| 9a8e4fe65c | |||
| c0104152e8 | |||
| aa5999cc25 | |||
| 3be4c4d5ee | |||
| 0990a23700 | |||
| cab16fc8f1 | |||
| ca6ccd9acf | |||
| b5a56a6a4e | |||
| 06b7b0ca6d | |||
| e214a74252 | |||
| 9eb86b23ce | |||
| aab39be662 | |||
| 2b4205a60a | |||
| 41e9e9f6d0 | |||
| c010d999f5 | |||
| f2f7767b71 | |||
| 353f1dc77d | |||
| 33e975ade3 | |||
| 86f86515be | |||
| c15f18685a | |||
| 00fb3d8156 | |||
| c78df0009b | |||
| d2cca26b1c | |||
| d25faf4fdf | |||
| 392def1322 | |||
| 401fd39ec0 | |||
| f9abff0764 | |||
| 5f4399f54d | |||
| 6340f479ae | |||
| 4185ebe3c5 |
@@ -0,0 +1,15 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/filtermail"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
@@ -20,9 +20,9 @@ concurrency:
|
||||
jobs:
|
||||
no-dns:
|
||||
name: LXC deploy and test
|
||||
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6
|
||||
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@main
|
||||
with:
|
||||
cmlxc_version: v0.14.6
|
||||
cmlxc_version: main
|
||||
cmlxc_commands: |
|
||||
cmlxc init
|
||||
# single cmdeploy relay test
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
- name: download filtermail
|
||||
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.6.6/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
|
||||
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.7.4/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
|
||||
- name: run chatmaild tests
|
||||
working-directory: chatmaild
|
||||
run: pipx run tox
|
||||
@@ -57,9 +57,9 @@ jobs:
|
||||
|
||||
lxc-test:
|
||||
name: LXC deploy and test
|
||||
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6
|
||||
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@main
|
||||
with:
|
||||
cmlxc_version: v0.14.6
|
||||
cmlxc_version: main
|
||||
cmlxc_commands: |
|
||||
cmlxc init
|
||||
# single cmdeploy relay test
|
||||
|
||||
@@ -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.9.1
|
||||
- uses: taiki-e/install-action@2ca9b94c269419b7b0c711c09d0b21c4e1d51145 # v2.83.1
|
||||
with:
|
||||
tool: taplo-cli
|
||||
- run: cd filtermail && 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.9.1
|
||||
- run: cd filtermail && 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.9.1
|
||||
- run: cd filtermail && 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/filtermail && 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/filtermail/target/x86_64-unknown-linux-musl/release/filtermail cm0
|
||||
cmlxc -v deploy-cmdeploy --filtermail repo/filtermail/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
|
||||
@@ -0,0 +1,69 @@
|
||||
on:
|
||||
tags:
|
||||
- 'filtermail-*'
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions: {}
|
||||
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.2.1
|
||||
- uses: taiki-e/install-action@2ca9b94c269419b7b0c711c09d0b21c4e1d51145 # v2.83.1
|
||||
with:
|
||||
tool: cargo-zigbuild
|
||||
- name: Build
|
||||
run: |
|
||||
RUSTFLAGS="-Ctarget-feature=+crt-static -Clink-self-contained=yes" \
|
||||
cd filtermail && 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/*
|
||||
@@ -78,6 +78,7 @@ docs/_build/
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
filtermail/target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
@@ -8,7 +8,12 @@ Chatmail relay servers are interoperable Mail Transport Agents (MTAs) designed f
|
||||
- **Instant/Realtime:** sub-second message delivery, realtime P2P
|
||||
streaming, privacy-preserving Push Notifications for Apple, Google, and Huawei;
|
||||
|
||||
- **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted
|
||||
- **Security Enforcement**: Only connections with strict TLS are accepted;
|
||||
all messages must be correctly signed with DKIM and OpenPGP-encrypted with minimized metadata.
|
||||
There are experimental exceptions for no-DNS relays,
|
||||
which are allowed use self-signed TLS certificates
|
||||
and which do not need to DKIM-sign their messages.
|
||||
Unencrypted messages are allowed in neither case.
|
||||
|
||||
- **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating
|
||||
depends on established IETF standards and protocols.
|
||||
|
||||
@@ -9,8 +9,7 @@ dependencies = [
|
||||
"iniconfig",
|
||||
"filelock",
|
||||
"requests",
|
||||
"crypt-r >= 3.13.1 ; python_version >= '3.11'",
|
||||
"domain-validator",
|
||||
"crypt-r >= 3.13.1 ; python_version >= '3.13'",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -2,7 +2,6 @@ import ipaddress
|
||||
from pathlib import Path
|
||||
|
||||
import iniconfig
|
||||
from domain_validator import DomainValidator
|
||||
|
||||
from chatmaild.user import User
|
||||
|
||||
@@ -25,7 +24,6 @@ class Config:
|
||||
self.mail_domain = f"[{raw_domain}]"
|
||||
self.postfix_myhostname = ipaddress.IPv4Address(raw_domain).reverse_pointer
|
||||
else:
|
||||
DomainValidator().validate_domain_re(raw_domain)
|
||||
self.ipv4_relay = None
|
||||
self.mail_domain = raw_domain
|
||||
self.postfix_myhostname = raw_domain
|
||||
|
||||
@@ -168,6 +168,16 @@ class Expiry:
|
||||
if mbox.last_login and mbox.last_login < cutoff_without_login:
|
||||
self.remove_mailbox(mbox.basedir)
|
||||
return
|
||||
elif mbox.last_login is None:
|
||||
try:
|
||||
if not self.dry:
|
||||
os.rmdir(mbox.basedir)
|
||||
self.del_mboxes += 1
|
||||
except OSError:
|
||||
print_info(
|
||||
f"Skipped deleting {mbox.basedir}, doesn't have last_login but isn't empty"
|
||||
)
|
||||
return
|
||||
|
||||
mboxname = os.path.basename(mbox.basedir)
|
||||
if self.verbose:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import itertools
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
from fnmatch import fnmatch
|
||||
@@ -9,6 +10,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from chatmaild.expire import (
|
||||
Expiry,
|
||||
FileEntry,
|
||||
MailboxStat,
|
||||
expire_to_target,
|
||||
@@ -104,6 +106,32 @@ def test_stats_mailbox(mbox1):
|
||||
assert mbox3.last_login is None
|
||||
|
||||
|
||||
def test_mbox_without_password(mbox1, example_config, capsys):
|
||||
password = Path(mbox1.basedir).joinpath("password")
|
||||
os.remove(password)
|
||||
mbox_rescan = MailboxStat(mbox1.basedir)
|
||||
assert mbox_rescan.last_login is None
|
||||
|
||||
exp = Expiry(
|
||||
example_config, dry=False, now=datetime.now().timestamp(), verbose=False
|
||||
)
|
||||
exp.process_mailbox_stat(mbox_rescan)
|
||||
out, err = capsys.readouterr()
|
||||
assert "doesn't have last_login but isn't empty" in err
|
||||
assert os.path.isdir(mbox_rescan.basedir)
|
||||
|
||||
for entry in os.scandir(mbox_rescan.basedir):
|
||||
if os.path.isdir(entry):
|
||||
shutil.rmtree(entry)
|
||||
else:
|
||||
os.remove(entry)
|
||||
|
||||
exp.process_mailbox_stat(mbox_rescan)
|
||||
out, err = capsys.readouterr()
|
||||
assert "doesn't have last_login but isn't empty" not in err
|
||||
assert not os.path.isdir(mbox_rescan.basedir)
|
||||
|
||||
|
||||
def test_report_no_mailboxes(example_config):
|
||||
args = (str(example_config._inipath),)
|
||||
report_main(args)
|
||||
|
||||
@@ -19,8 +19,8 @@ dependencies = [
|
||||
"pytest-xdist",
|
||||
"execnet",
|
||||
"imap_tools",
|
||||
"deltachat-rpc-client==2.49.0",
|
||||
"deltachat-rpc-server==2.49.0",
|
||||
"deltachat-rpc-client",
|
||||
"deltachat-rpc-server",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
"acme-enter-email": "{{ email }}"
|
||||
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.6-August-18-2025.pdf": true
|
||||
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.8-July-06-2026.pdf": true
|
||||
|
||||
@@ -166,7 +166,7 @@ class Deployer:
|
||||
return self.put_template(src, dest, **kwargs)
|
||||
return self.put_file(src, dest)
|
||||
|
||||
def put_file(self, src, dest, mode="644"):
|
||||
def put_file(self, src, dest, mode="644", **kwargs):
|
||||
if isinstance(src, str):
|
||||
src = get_resource(src)
|
||||
res = files.put(
|
||||
@@ -176,6 +176,7 @@ class Deployer:
|
||||
user="root",
|
||||
group="root",
|
||||
mode=mode,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return self._update_restart_signals(dest, res)
|
||||
|
||||
@@ -164,6 +164,7 @@ class UnboundDeployer(Deployer):
|
||||
self.put_file(
|
||||
src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"),
|
||||
dest="/etc/resolv.conf",
|
||||
force=True,
|
||||
)
|
||||
server.shell(
|
||||
name="Generate root keys for validating DNSSEC",
|
||||
@@ -171,16 +172,14 @@ class UnboundDeployer(Deployer):
|
||||
"unbound-anchor -a /var/lib/unbound/root.key || true",
|
||||
],
|
||||
)
|
||||
if self.config.disable_ipv6:
|
||||
self.ensure_directory(
|
||||
path="/etc/unbound/unbound.conf.d",
|
||||
)
|
||||
self.put_template(
|
||||
"unbound/unbound.conf.j2",
|
||||
"/etc/unbound/unbound.conf.d/chatmail.conf",
|
||||
)
|
||||
else:
|
||||
self.remove_file("/etc/unbound/unbound.conf.d/chatmail.conf")
|
||||
self.ensure_directory(
|
||||
path="/etc/unbound/unbound.conf.d",
|
||||
)
|
||||
self.put_template(
|
||||
"unbound/unbound.conf.j2",
|
||||
"/etc/unbound/unbound.conf.d/chatmail.conf",
|
||||
disable_ipv6=self.config.disable_ipv6,
|
||||
)
|
||||
|
||||
def activate(self):
|
||||
server.shell(
|
||||
@@ -514,6 +513,8 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
||||
(["master", "smtpd"], config.postfix_reinject_port_incoming),
|
||||
("filtermail", config.filtermail_smtp_port),
|
||||
("filtermail", config.filtermail_smtp_port_incoming),
|
||||
("filtermail", config.filtermail_http_port_incoming),
|
||||
("filtermail", config.filtermail_lmtp_port_transport),
|
||||
]
|
||||
for service, port in port_services:
|
||||
print(f"Checking if port {port} is available for {service}...")
|
||||
|
||||
@@ -20,10 +20,10 @@ class FiltermailDeployer(Deployer):
|
||||
return
|
||||
|
||||
arch = host.get_fact(facts.server.Arch)
|
||||
url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.6/filtermail-{arch}"
|
||||
url = f"https://github.com/chatmail/filtermail/releases/download/v0.7.4/filtermail-{arch}"
|
||||
sha256sum = {
|
||||
"x86_64": "05c7e7ac244606c2eeb275f2d282ffdbc2403e0169f1cdd3110ffcebdb994a92",
|
||||
"aarch64": "8cf8bbda4d907beca547b365cc7e6753532a74b1712492d0d2f3d2d8a553fb3d",
|
||||
"x86_64": "484cb8dff083134aefba9fce4a6b7ef4784a0f0e28e5108ecf8bb9e58a44fd2c",
|
||||
"aarch64": "66aa0ca2ca9add7a12d92883d76f8786384092adfde24a3d3a1d0b1f30d23a9e",
|
||||
}[arch]
|
||||
self.download_executable(url, self.bin_path, sha256sum)
|
||||
|
||||
|
||||
@@ -73,6 +73,11 @@ counter incoming_unencrypted_mail_count
|
||||
filtered_incoming_mail_count++
|
||||
}
|
||||
|
||||
counter incoming_mailer_daemon_mail_count
|
||||
/Incoming: Filtering mailer-daemon message from/ {
|
||||
incoming_mailer_daemon_mail_count++
|
||||
filtered_incoming_mail_count++
|
||||
}
|
||||
|
||||
counter rejected_unencrypted_mail_count
|
||||
/Rejected unencrypted mail/ {
|
||||
|
||||
@@ -85,6 +85,14 @@ inet_protocols = all
|
||||
|
||||
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
|
||||
|
||||
# Do not apply header checks to MIME headers
|
||||
# and other headers that are actually part of the message body.
|
||||
# Reference:
|
||||
# <https://www.postfix.org/postconf.5.html#disable_mime_input_processing>
|
||||
# <https://www.postfix.org/header_checks.5.html>
|
||||
# <https://stump.io/blog/2020/11/29/a-little-gotcha-with-postfixs-header_checks/>
|
||||
disable_mime_input_processing = yes
|
||||
|
||||
mua_client_restrictions = permit_sasl_authenticated, reject
|
||||
mua_sender_restrictions = reject_sender_login_mismatch, permit_sasl_authenticated, reject
|
||||
mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit
|
||||
|
||||
@@ -76,7 +76,6 @@ lmtp unix - - y - - lmtp
|
||||
anvil unix - - y - 1 anvil
|
||||
scache unix - - y - 1 scache
|
||||
postlog unix-dgram n - n - 1 postlogd
|
||||
filter unix - n n - - lmtp
|
||||
# Local SMTP server for reinjecting outgoing filtered mail.
|
||||
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
|
||||
-o syslog_name=postfix/reinject
|
||||
|
||||
@@ -10,13 +10,11 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from chatmaild.config import is_valid_ipv4, read_config
|
||||
from domain_validator import DomainValidator
|
||||
|
||||
|
||||
def format_mail_domain(raw_domain: str) -> str:
|
||||
if is_valid_ipv4(raw_domain):
|
||||
return f"[{raw_domain}]"
|
||||
DomainValidator().validate_domain_re(raw_domain)
|
||||
return raw_domain
|
||||
|
||||
|
||||
@@ -349,9 +347,9 @@ class ChatmailACFactory:
|
||||
qr = (
|
||||
f"dclogin:{addr}"
|
||||
f"?p={password}&v=1"
|
||||
f"&ih={domain}&ip=993"
|
||||
f"&sh={domain}&sp=465"
|
||||
f"&ic=3&ss=default"
|
||||
f"&ih={domain}&ip=993&is=ssl"
|
||||
f"&sh={domain}&sp=465&ss=ssl"
|
||||
f"&ic=3"
|
||||
)
|
||||
future = account.add_transport_from_qr.future(qr)
|
||||
else:
|
||||
@@ -362,7 +360,7 @@ class ChatmailACFactory:
|
||||
|
||||
# ensure messages stay in INBOX so that they can be
|
||||
# concurrently fetched via extra IMAP connections during tests
|
||||
account.set_config("delete_server_after", "10")
|
||||
account.set_config("bcc_self", "1")
|
||||
accounts.append(account)
|
||||
|
||||
for future in futures:
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# Managed by cmdeploy: disable IPv6 in unbound.
|
||||
# Managed by cmdeploy
|
||||
server:
|
||||
{% if disable_ipv6 %}
|
||||
interface: 127.0.0.1
|
||||
do-ip6: no
|
||||
{% endif %}
|
||||
cache-max-negative-ttl: 0
|
||||
|
||||
+41
-15
@@ -156,6 +156,7 @@ Chatmail relay dependency diagram
|
||||
postfix --- |10083|filtermail-transport;
|
||||
filtermail-outgoing --- |10025 reinject|postfix;
|
||||
filtermail-incoming --- |10026 reinject|postfix;
|
||||
postfix --- |milter opendkim.sock|OpenDKIM
|
||||
dovecot --- |doveauth.socket|doveauth;
|
||||
dovecot --- |message delivery|maildir["maildir
|
||||
/home/vmail/.../user"];
|
||||
@@ -179,26 +180,51 @@ Chatmail relay dependency diagram
|
||||
style nginx-right fill:#f66;
|
||||
style postfix fill:#f66;
|
||||
style dovecot fill:#f66;
|
||||
style OpenDKIM fill:#f66;
|
||||
style notification-proxy fill:#f66;
|
||||
|
||||
Message between users on the same relay
|
||||
---------------------------------------
|
||||
Accepting and delivering mail
|
||||
-----------------------------
|
||||
|
||||
.. mermaid::
|
||||
:caption: This diagram shows the path a non-federated message takes.
|
||||
:caption: This diagram shows all the paths a message can take.
|
||||
|
||||
graph LR;
|
||||
sender --> |465|smtps/smtpd;
|
||||
sender --> |587|submission/smtpd;
|
||||
smtps/smtpd --> |10080|filtermail;
|
||||
submission/smtpd --> |10080|filtermail;
|
||||
filtermail --> |10025|smtpd_reinject;
|
||||
smtpd_reinject --> cleanup;
|
||||
cleanup --> qmgr;
|
||||
qmgr --> smtpd_accepts_message;
|
||||
qmgr --> |lmtp|dovecot;
|
||||
dovecot --> recipient;
|
||||
dovecot --> sender's_other_devices;
|
||||
flowchart LR
|
||||
subgraph chatmail relay
|
||||
subgraph postfix
|
||||
qmgr .-> lmtp-filtermail["lmtp/lmtp-filtermail (default_transport)"]
|
||||
qmgr .-> lmtp["lmtp (local_transport)"]
|
||||
lmtp --> cleanup["cleanup (lmtp_header_cleanup)"]
|
||||
bounce
|
||||
smtpd-submission["smtpd/submission"]
|
||||
smtpd-smtps["smtpd/smtps"]
|
||||
smtpd-reinject-outgoing["smtpd/reinject-outgoing"] --> authclean["cleanup/authclean (submission_header_cleanup)"]
|
||||
authclean --> qmgr
|
||||
smtpd-smtp["smtpd/smtp"]
|
||||
smtpd-reinject-incoming["smtpd/reinject-incoming"] --> qmgr
|
||||
end
|
||||
lmtp-filtermail --LMTP inet:10083--> filtermail-transport
|
||||
cleanup --LMTP unix:private/dovecot-lmtp --> dovecot
|
||||
dovecot --> maildir
|
||||
smtpd-submission --SMTP inet:10080--> filtermail-outgoing
|
||||
smtpd-smtps --SMTP inet:10080--> filtermail-outgoing
|
||||
filtermail-outgoing --SMTP inet:10025--> smtpd-reinject-outgoing
|
||||
open-dkim["OpenDKIM (signing only)"] <--milter unix:opendkim/opendkim.sock--> smtpd-reinject-outgoing
|
||||
bounce <--milter unix:opendkim/opendkim.sock--> open-dkim
|
||||
bounce --> qmgr
|
||||
nginx
|
||||
smtpd-smtp -.SMTP inet:10081.-> filtermail-incoming
|
||||
nginx -.HTTP inet:10082.-> filtermail-incoming
|
||||
filtermail-incoming --SMTP inet:10026--> smtpd-reinject-incoming
|
||||
end
|
||||
filtermail-transport -.SMTP inet:25.-> mta1[Remote relay]
|
||||
filtermail-transport -.HTTPS /mxdeliv.-> mta1
|
||||
client[Client] -.SMTP inet:587.-> smtpd-submission
|
||||
client -.SMTP inet:465.-> smtpd-smtps
|
||||
client -.SMTP inet:443.-> nginx
|
||||
nginx -.SMTP inet:465.-> smtpd-smtps
|
||||
mta2[Remote relay] -.SMTP inet:25.-> smtpd-smtp
|
||||
mta2 -.HTTPS /mxdeliv.-> nginx
|
||||
|
||||
Operational details of a chatmail relay
|
||||
----------------------------------------
|
||||
|
||||
@@ -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