Compare commits

...

168 Commits

Author SHA1 Message Date
missytake c82412dc4c chore: integrate filtermail's .github directory into relay's 2026-07-29 16:06:10 +02:00
missytake 827bd5fc4f Merging chatmail/filtermail repository into chatmail/relay 2026-07-26 12:23:15 +02:00
dependabot[bot] 6b3d475c82 chore(deps): Bump dtolnay/rust-toolchain (#203)
Bumps [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from fa04a1451ff1842e2626ccb99004d0195b455a88 to 2c7215f132e9ebf062739d9130488b56d53c060c.
- [Release notes](https://github.com/dtolnay/rust-toolchain/releases)
- [Commits](https://github.com/dtolnay/rust-toolchain/compare/fa04a1451ff1842e2626ccb99004d0195b455a88...2c7215f132e9ebf062739d9130488b56d53c060c)

---
updated-dependencies:
- dependency-name: dtolnay/rust-toolchain
  dependency-version: 2c7215f132e9ebf062739d9130488b56d53c060c
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 15:35:16 +02:00
dependabot[bot] a0fe9c4b2c chore(deps): Bump taiki-e/install-action from 2.82.5 to 2.83.1 (#202)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.82.5 to 2.83.1.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/bffeee26d4db9be238a4ea78d8826604ebcb594d...2ca9b94c269419b7b0c711c09d0b21c4e1d51145)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.83.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 15:34:50 +02:00
link2xt e47740f45f refactor: do not evaluate smtp_write! argument twice 2026-07-10 08:03:45 +00:00
dependabot[bot] 678bf2a684 chore(deps): Bump taiki-e/install-action from 2.82.2 to 2.82.5 (#198) 2026-07-04 15:04:58 +09:00
dependabot[bot] 9613b2f14f chore(deps): Bump webpki-roots from 1.0.7 to 1.0.8 (#196) 2026-07-04 15:04:06 +09:00
dependabot[bot] 2bba729b6b chore(deps): Bump memchr from 2.8.1 to 2.8.2 (#195) 2026-07-04 15:02:50 +09:00
dependabot[bot] df6c9d777d chore(deps): Bump log from 0.4.30 to 0.4.33 (#194) 2026-07-04 15:02:06 +09:00
dependabot[bot] 88e2124d59 chore(deps): Bump hyper from 1.9.0 to 1.10.1 (#197)
Bumps [hyper](https://github.com/hyperium/hyper) from 1.9.0 to 1.10.1.
- [Release notes](https://github.com/hyperium/hyper/releases)
- [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper/compare/v1.9.0...v1.10.1)

---
updated-dependencies:
- dependency-name: hyper
  dependency-version: 1.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 08:29:20 +02:00
Jagoda Estera Ślązak 5717940ac1 chore(release): prepare for 0.7.4 (#193)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-07-01 15:15:41 +02:00
l ed5dfbe0ff test: place #[tokio::test] after rstest case macros (#192)
Otherwise only the first case is built.
2026-07-01 14:51:37 +02:00
dependabot[bot] 5cdae22d31 chore(deps): Bump dtolnay/rust-toolchain (#188)
Bumps [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from 3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 to fa04a1451ff1842e2626ccb99004d0195b455a88.
- [Release notes](https://github.com/dtolnay/rust-toolchain/releases)
- [Commits](https://github.com/dtolnay/rust-toolchain/compare/3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9...fa04a1451ff1842e2626ccb99004d0195b455a88)

---
updated-dependencies:
- dependency-name: dtolnay/rust-toolchain
  dependency-version: 67ef31d5b988238dd797d409d6f9574278e20537
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 13:48:43 +02:00
dependabot[bot] d00d2b870b chore(deps): Bump taiki-e/install-action from 2.81.10 to 2.82.2 (#187)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.81.10 to 2.82.2.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/7a79fe8c3a13344501c80d99cae481c1c9085912...9e1e5806d4a4822de933115878265be9aaa786d9)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.82.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 19:01:23 +02:00
Jagoda Estera Ślązak 7f14693b31 feat(logs): Log incoming mailer-daemon message sources (#191)
Logs mailer-daemon messages separately from other unencrypted mails.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-30 19:00:13 +02:00
dependabot[bot] 0fce0a2191 chore(deps): Bump actions/checkout from 6.0.3 to 7.0.0 (#186)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.3 to 7.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 18:10:18 +02:00
Jagoda Estera Ślązak 4d7bcf51b7 chore: update filtermail.mtail (#190)
Logs for filtermail-transport changed in v0.7.2+.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-30 12:05:21 +02:00
Jagoda Estera Ślązak 222bf36712 chore(release): prepare for 0.7.3 (#189)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-27 17:13:53 +02:00
Jagoda Estera Ślązak d5ac67ecff feat(transport): Worker eviction (#185)
Adds max worker capacity of 500 to the worker pool,
and an automatic shutdown of idle workers.
Messages that would cause the capacity to be exceeded,
are deferred.

Additionally, ensures that the same worker
is not spawned by two tasks at the same time.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-27 10:54:38 +02:00
Jagoda Estera Ślązak ac9df36801 chore(release): prepare for 0.7.2 (#184) 2026-06-26 21:04:42 +09:00
Jagoda Ślązak 03ca4a010b docs(readme): Update Transport mode doc
Add information about workers,
and synchronization of deliveries.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-26 11:38:25 +02:00
Jagoda Ślązak db86cfc6ed refactor: Implement Display for AddressDomain
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-26 11:11:30 +02:00
Jagoda Ślązak c7452526fa test: Test filtermail-transport
Adds filtermail-transport tests and
a test setup for recording SMTP transactions.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-26 11:11:20 +02:00
Jagoda Ślązak 7ea7904ea0 feat(transport): Destination worker pool
Implements a per-destination worker pool,
so that connections to the same destination
are not parallelized, but instead queued.
If a queue is full, new messages are immediately
deferred, before mail data is sent from postfix.

Closes: #141

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-26 11:07:34 +02:00
dependabot[bot] 7d0db2c46e chore(deps): Bump taiki-e/install-action from 2.81.6 to 2.81.10 (#183)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.81.6 to 2.81.10.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/59012be0884e296ca2da49b530610e72c49039ad...7a79fe8c3a13344501c80d99cae481c1c9085912)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.81.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 18:29:12 +02:00
dependabot[bot] b36856a383 chore(deps): Bump taiki-e/install-action from 2.79.14 to 2.81.6 (#182)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.79.14 to 2.81.6.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/873c7452cadb7c034694a1282227095d93fbdf92...59012be0884e296ca2da49b530610e72c49039ad)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.81.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 13:42:27 +02:00
dependabot[bot] a66887b778 chore(deps): Bump actions/checkout from 6.0.2 to 6.0.3 (#181)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 13:41:54 +02:00
Jagoda Estera Ślązak 488bbcb182 docs(readme): Disable colors in mermaid diagrams (#180)
Previously used colors caused
diagrams to be unreadable in
light mode.
Removing styling completely,
as this was redundant anyway.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-10 22:31:53 +02:00
Jagoda Estera Ślązak 81aac3cb77 chore(release): prepare for 0.7.1 (#179)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-09 13:45:03 +02:00
Jagoda Estera Ślązak d3f5412420 feat: switch to aws-lc-rs cryptography provider (#178)
Switches crypto provider to keep
chatmail codebase consistent,
analogous to https://github.com/chatmail/core/pull/8313

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-06 15:28:53 +02:00
dependabot[bot] 73b684f90c chore(deps): Bump log from 0.4.29 to 0.4.30 (#173)
Bumps [log](https://github.com/rust-lang/log) from 0.4.29 to 0.4.30.
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.29...0.4.30)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-06 10:20:04 +02:00
l fb5a25939c fix: ignore CNAME records when resolving TXT records (#177) 2026-06-06 16:55:18 +09:00
dependabot[bot] 939e5367c9 chore(deps): Bump taiki-e/install-action from 2.79.6 to 2.79.14
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.79.6 to 2.79.14.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/f48d2f8ba2b452934c948b7be1a768079c3632ff...873c7452cadb7c034694a1282227095d93fbdf92)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.79.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-05 22:51:57 +00:00
dependabot[bot] b0214a71d7 chore(deps): Bump memchr from 2.8.0 to 2.8.1
Bumps [memchr](https://github.com/BurntSushi/memchr) from 2.8.0 to 2.8.1.
- [Commits](https://github.com/BurntSushi/memchr/compare/2.8.0...2.8.1)

---
updated-dependencies:
- dependency-name: memchr
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-05 22:51:26 +00:00
dependabot[bot] 2538caa1b2 chore(deps): Bump lru from 0.17.0 to 0.18.0 (#171)
Bumps [lru](https://github.com/jeromefroe/lru-rs) from 0.17.0 to 0.18.0.
- [Changelog](https://github.com/jeromefroe/lru-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jeromefroe/lru-rs/compare/0.17.0...0.18.0)

---
updated-dependencies:
- dependency-name: lru
  dependency-version: 0.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 14:57:12 +02:00
dependabot[bot] f82f34d054 chore(deps): Bump tokio from 1.52.1 to 1.52.3 (#172)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.52.1 to 1.52.3.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.1...tokio-1.52.3)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 14:56:45 +02:00
Andrey 0xdc09 8a60245183 fix(resolver): disable negative caching (#170) 2026-06-02 12:04:32 +02:00
j4n 6d6517f8f3 chore: add filtermail.mtail so filtermail failures can be monitored (#169)
Example Prometheus queries:

Outbound errors by reason:
  sum by (reason) (rate(filtermail_transport_error_total[5m]))

filtermail-incoming drops by reason:
  sum by (reason) (rate(filtermail_inbound_drop_total[5m]))
2026-06-01 21:51:34 +02:00
Jagoda Estera Ślązak e9a63222e6 fix(smtp-server): Correct error when EOF while reading DATA (#168)
Log
`Unexpected EoF while receiving DATA!`
instead of
`Malformed DATA line without CRLF ending!`

Fixes: #165

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-01 16:27:30 +02:00
dependabot[bot] 893ceab107 chore(deps): Bump taiki-e/install-action from 2.75.28 to 2.79.6 (#164)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.28 to 2.79.6.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/51cd0b8c0499559d9a4d75c0f5c67bec3a894ec8...f48d2f8ba2b452934c948b7be1a768079c3632ff)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.79.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 09:51:57 +02:00
l a6361a2194 fix: switch ratelimiter to MonotonicClock (#167)
There is a known bug that `quanta` clock
used by default sometimes jumps back:
<https://github.com/metrics-rs/quanta/issues/111>.
This results in `governor` rate limiter
incorrectly rejecting the messages
even when less than the configured burst size
has been sent.

Switched to standard monotonic clock
which does not have this problem.
I tested that this fixed the problem for me,
chatmail relay test `test_exceed_rate_limit`
stopped failing randomly.

Fixes <https://github.com/chatmail/filtermail/issues/166>
2026-06-01 09:51:33 +02:00
Jagoda Estera Ślązak febfb90510 chore(release): prepare for 0.7.0 (#163)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-26 06:56:00 +02:00
Jagoda Estera Ślązak c84a96ac78 docs(readme): Remove docs for options removed in 664ad57 (#162) 2026-05-26 02:25:51 +09:00
link2xt 847e19546b fix: do not crash if accepting new connection fails
If we run out of file descriptors, we cannot do anything
to accept queued connections, so at least don't crash the process.
Fixes <https://github.com/chatmail/filtermail/issues/139>.
2026-05-14 20:59:59 +00:00
holger krekel 664ad571b6 feat!: remove passthrough options that allowed unencrypted mail to pass
see https://github.com/chatmail/relay/pull/970/ for the related removal of chatmail.ini options
2026-05-14 20:59:13 +02:00
Jagoda Estera Ślązak 91d09ab370 chore(release): prepare for 0.6.6 (#156)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-12 13:02:17 +02:00
holger krekel e2be5dc92d ci: run cmlxc tests in all classic/classic-ipv4/madmail combinations 2026-05-12 12:46:20 +02:00
Jagoda Estera Ślązak d74e5b85b3 feat: Improved SMTP error responses (#147)
Fixes: #140

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-12 12:37:02 +02:00
Jagoda Estera Ślązak b7a213ff1b ci: Remove mac and windows from matrix tests (#154)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-12 12:20:25 +02:00
holger krekel 3896044f36 fix: return HTTP 200 because madmail expects it, and make sure https is immediately retried when SMTP fails (#153) 2026-05-12 12:13:00 +02:00
Jagoda Estera Ślązak e26cca0260 chore(release): prepare for 0.6.5 (#152)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-12 09:52:13 +02:00
dependabot[bot] 272744115f chore(deps): Bump taiki-e/install-action from 2.75.22 to 2.75.28 (#148)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.22 to 2.75.28.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/cf525cb33f51aca27cd6fa02034117ab963ff9f1...51cd0b8c0499559d9a4d75c0f5c67bec3a894ec8)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.75.28
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-12 07:01:30 +02:00
l a65e7a1490 fix: Validate mail data (#150)
Do not allow bare CR and LF etc.
2026-05-12 07:00:21 +02:00
holger krekel 36921dd29f fix: advertise 8BITMIME to prevent conversion after DKIM signing (#149)
for bounces postfix sets Content-Transfer-Encoding: 8bit
which causes a quoted-printable conversion when filtermail SMTP does not offer 8BITMIME,
which in turn breaks DKIM verification.
2026-05-12 06:56:11 +02:00
Jagoda Estera Ślązak 5295d175d6 perf(smtp-client): Use pipelining if server advertises support (#146)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-07 15:05:51 +02:00
Jagoda Estera Ślązak 10119033a3 fix(smtp-client): Handle 421 on reused connection (#145)
If a reused connection was closed
in the meantime, open a new connection,
instead of failing immediately.

Fixes #143

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-07 14:20:49 +02:00
Jagoda Estera Ślązak d29cbafda4 chore(deps): Upgrade hickory-resolver (#138) 2026-05-05 02:17:21 +02:00
dependabot[bot] bc769e0d2a chore(deps): Bump webpki-roots from 1.0.6 to 1.0.7 (#136)
Bumps [webpki-roots](https://github.com/rustls/webpki-roots) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/rustls/webpki-roots/releases)
- [Commits](https://github.com/rustls/webpki-roots/compare/v/1.0.6...v/1.0.7)

---
updated-dependencies:
- dependency-name: webpki-roots
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 12:54:28 +02:00
dependabot[bot] 59944a46ff chore(deps): Bump tokio from 1.51.1 to 1.52.1 (#135)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.51.1 to 1.52.1.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.51.1...tokio-1.52.1)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 12:52:55 +02:00
dependabot[bot] 457461b4ff chore(deps): Bump lru from 0.16.3 to 0.17.0 (#133)
Bumps [lru](https://github.com/jeromefroe/lru-rs) from 0.16.3 to 0.17.0.
- [Changelog](https://github.com/jeromefroe/lru-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jeromefroe/lru-rs/compare/0.16.3...0.17.0)

---
updated-dependencies:
- dependency-name: lru
  dependency-version: 0.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 09:45:26 +02:00
dependabot[bot] 94dcd6c728 chore(deps): Bump taiki-e/install-action from 2.75.17 to 2.75.22 (#132)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.17 to 2.75.22.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/58e862542551f667fa44c8a2a4a1d64ad477c96a...cf525cb33f51aca27cd6fa02034117ab963ff9f1)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.75.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 09:44:33 +02:00
holger krekel 36ff21ff91 ci: build and deploy relays with filtermail binary and run interop tests against madmail (#131)
* ci: Run relay integration tests using cmlxc

Builds filtermail and runs cmlxc
tests against a patched chatmail/relay.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>

* ci: run integration tests with branch filtermail binary deployed with chatmail/relay main code

---------

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
Co-authored-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-01 17:58:11 +02:00
Jagoda Estera Ślązak 5b79d2d08a chore(release): prepare for 0.6.4 (#130)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-01 10:39:46 +02:00
Jagoda Estera Ślązak 0a652e61ee feat: https transport channel (#122)
Transport mode:
Implements an additional mail delivery
channel over HTTPS.

Incoming mode:
Adds a http server listening for
incoming messages delivered over HTTPS.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-01 10:30:09 +02:00
Jagoda Ślązak 1b1585eb7d refactor(transport): Explicitly handle RFC7505 null MX
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-04-28 08:24:27 +02:00
Jagoda Ślązak 21aaea0d92 fix: Implement missing "implicit MX" rule
Implements "implicit MX" as described by
https://datatracker.ietf.org/doc/html/rfc5321#section-5.1

Fixes: #126
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-04-27 14:44:21 +02:00
dependabot[bot] c1a4d99d2e chore(deps): Bump taiki-e/install-action from 2.75.5 to 2.75.17 (#125)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.5 to 2.75.17.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/7a4939c09608b2a1986b484eca1d16fd0db8ebef...58e862542551f667fa44c8a2a4a1d64ad477c96a)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.75.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 10:36:58 +02:00
dependabot[bot] 17ccd3e2ca chore(deps): Bump bnjbvr/cargo-machete from 0.9.1 to 0.9.2 (#124)
Bumps [bnjbvr/cargo-machete](https://github.com/bnjbvr/cargo-machete) from 0.9.1 to 0.9.2.
- [Release notes](https://github.com/bnjbvr/cargo-machete/releases)
- [Changelog](https://github.com/bnjbvr/cargo-machete/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bnjbvr/cargo-machete/compare/7959c845782fed02ee69303126d4a12d64f1db18...ac30a525c0a8d163a92d727b3ff079ee3f6ecb08)

---
updated-dependencies:
- dependency-name: bnjbvr/cargo-machete
  dependency-version: 0.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 10:36:40 +02:00
Jagoda Estera Ślązak 3eb3b86bc9 chore(release): prepare for 0.6.3 (#121)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-04-20 14:09:53 +02:00
Jagoda Estera Ślązak 1e356315da feat(smtp-server): Log malformed SMTP commands (#118)
Closes #110

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-04-20 13:03:49 +02:00
dependabot[bot] 0e1b7173ab chore(deps): Bump actions/upload-artifact from 7.0.0 to 7.0.1 (#119)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/bbbca2ddaa5d8feaa63e36b76fdaad77386f024f...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 10:39:24 +02:00
dependabot[bot] e156ce9125 chore(deps): Bump taiki-e/install-action from 2.71.2 to 2.75.5 (#120)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.71.2 to 2.75.5.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/d858f8113943481093e02986a7586a4819a3bfd6...7a4939c09608b2a1986b484eca1d16fd0db8ebef)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.75.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 10:39:00 +02:00
Jagoda Estera Ślązak dee00ebdcf perf(smtp-client): Cache connections (#117) 2026-04-17 11:37:05 +02:00
Jagoda Estera Ślązak 45eaa2f659 chore(release): prepare for 0.6.2 (#116)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-04-16 09:25:59 +02:00
Jagoda Estera Ślązak cf64778f71 feat(transport): Remote delivery over SMTP (#104)
Implements a new mode "transport",
that allows filtermail to be used for
remote delivery.

In transport mode, filtermail listens for LMTP
connections, splits messages by domain
and performs delivery to remote MTAs over SMTP.

SMTP client tries to open socket on all resolved
addresses in parallel and uses one that succeeds
the first, fixing the issue described in:
chatmail/relay#900

Extends the built-in SMTP client
with STARTTLS support.

Extends the built-in SMTP server
with LMTP greeting support.

Groundwork required for mxdeliv endpoint
(HTTP channel for MTA-to-MTA communication):
chatmail/relay#900

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-04-15 22:12:09 +02:00
Jagoda Estera Ślązak ec0413bd5f feat: Log disabled recipients (#113)
Logs disabled recipient addresses
when removing them from RCPT TO.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-04-15 22:00:42 +02:00
Jagoda Estera Ślązak 2450d1d9c7 docs(readme): Reformat README.md (#115)
Fixes: #91

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-04-09 11:27:48 +02:00
Jagoda Estera Ślązak 0feb1371b3 chore(dependabot): Update configuration (#114)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-04-07 13:53:48 +02:00
dependabot[bot] 8ad65923e9 chore(deps): Bump tokio from 1.50.0 to 1.51.0 (#111)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.50.0 to 1.51.0.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.50.0...tokio-1.51.0)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 12:46:59 +02:00
dependabot[bot] 5575df7510 chore(deps): Bump taiki-e/install-action from 2.69.13 to 2.71.2 (#112)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.69.13 to 2.71.2.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/704f92c11daa75bff5b4e01fcb083350c16c47b9...d858f8113943481093e02986a7586a4819a3bfd6)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.71.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 12:46:10 +02:00
Jagoda Estera Ślązak 0f58942998 chore(release): prepare for 0.6.1 (#109)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-04-01 23:21:32 +02:00
Jagoda Estera Ślązak 05cbf2908c feat: Add experimental option to disable mailboxes (#108)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-04-01 23:05:02 +02:00
dependabot[bot] 621eaeeffb chore(deps): Bump taiki-e/install-action from 2.69.4 to 2.69.13 (#107)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.69.4 to 2.69.13.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/a7e592b24782184db1613493a5e65d2e12199f80...704f92c11daa75bff5b4e01fcb083350c16c47b9)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.69.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 09:46:08 +02:00
dependabot[bot] ca3393cf48 chore(deps): Bump env_logger from 0.11.9 to 0.11.10 (#106)
Bumps [env_logger](https://github.com/rust-cli/env_logger) from 0.11.9 to 0.11.10.
- [Release notes](https://github.com/rust-cli/env_logger/releases)
- [Changelog](https://github.com/rust-cli/env_logger/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-cli/env_logger/compare/v0.11.9...v0.11.10)

---
updated-dependencies:
- dependency-name: env_logger
  dependency-version: 0.11.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 09:45:50 +02:00
dependabot[bot] a4497bc10d chore(deps): Bump dtolnay/rust-toolchain (#105)
Bumps [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from efa25f7f19611383d5b0ccf2d1c8914531636bf9 to 3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9.
- [Release notes](https://github.com/dtolnay/rust-toolchain/releases)
- [Commits](https://github.com/dtolnay/rust-toolchain/compare/efa25f7f19611383d5b0ccf2d1c8914531636bf9...3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9)

---
updated-dependencies:
- dependency-name: dtolnay/rust-toolchain
  dependency-version: 3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 09:45:24 +02:00
Jagoda Estera Ślązak fb77c5be81 feat(resolver): Enable DNSSEC (#94)
From hickory-resolver docs:
> To enable DNSSEC, enable the dnssec-ring feature.

Related: https://github.com/chatmail/relay/issues/877
Related: https://github.com/hickory-dns/hickory-dns/issues/3519
Closes: https://github.com/chatmail/filtermail/issues/93

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-03-24 08:27:48 +01:00
dependabot[bot] 7da0c34678 chore(deps): Bump taiki-e/install-action from 2.68.20 to 2.69.4 (#102)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.68.20 to 2.69.4.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/4a136ceac97fe29b942ff3b6c749a46c7950d853...a7e592b24782184db1613493a5e65d2e12199f80)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.69.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 10:11:12 +01:00
Andrey 0xdc09 9a4a54cea8 refactor: derive Default for Envelope (#100) 2026-03-16 10:01:52 +01:00
Andrey 0xdc09 2aa894f48f docs(readme): fix typo (#99)
As suggested in https://github.com/chatmail/filtermail/pull/84#issuecomment-4056084708
2026-03-16 08:49:50 +01:00
dependabot[bot] 23b28bc66e chore(deps): Bump Swatinem/rust-cache from 2.8.2 to 2.9.1 (#96)
Bumps [Swatinem/rust-cache](https://github.com/swatinem/rust-cache) from 2.8.2 to 2.9.1.
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/779680da715d629ac1d338a641029a2f4372abb5...c19371144df3bb44fab255c43d04cbc2ab54d1c4)

---
updated-dependencies:
- dependency-name: Swatinem/rust-cache
  dependency-version: 2.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-14 10:29:54 +01:00
dependabot[bot] b47b859fcb chore(deps): Bump actions/download-artifact from 8.0.0 to 8.0.1 (#95)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 8.0.0 to 8.0.1.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-14 10:28:29 +01:00
Andrey 0xdc09 49df79bfa0 feat: configurable hosts for listen and reinject (#84)
Adds configurable listen IP and postfix host 
via `filtermail_host` and `postfix_host` chatmail.ini config fields.
2026-03-13 17:04:44 +01:00
Jagoda Estera Ślązak b31e13a417 chore(release): prepare for 0.6.0 (#92)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-03-13 12:51:38 +01:00
Jagoda Estera Ślązak 8dd9436a0d feat!: Remove IP verification for domain-literals (#90)
BREAKING CHANGE: messages using domain-literal
  addresses don't require to match the origin
  SMTP connection IP anymore.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-03-13 12:25:45 +01:00
Jagoda Estera Ślązak 82a9850a4b docs(readme): Improve README.md (#88)
Fixes #85

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-03-12 18:15:08 +01:00
Jagoda Estera Ślązak 779e7ca2b4 docs(readme): Clarify licensing of the binaries
Co-authored-by: holger krekel  <holger@merlinux.eu>
2026-03-11 19:00:56 +01:00
Jagoda Ślązak 0d7f5d7b05 docs(readme): Fix license link
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-03-11 18:04:14 +01:00
dependabot[bot] 07496abab4 chore(deps): Bump tokio from 1.49.0 to 1.50.0 (#86)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.49.0 to 1.50.0.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.49.0...tokio-1.50.0)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-11 10:56:32 +01:00
dependabot[bot] a8033b107e chore(deps): Bump taiki-e/install-action from 2.68.13 to 2.68.20 (#87)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.68.13 to 2.68.20.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/a3324fb0eb94b8230ec968c3389c1b7929fc2f3b...4a136ceac97fe29b942ff3b6c749a46c7950d853)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.68.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-11 10:53:51 +01:00
Andrey 0xdc09 c088cbfe4d refactor: use enum for mode cli arg (#83) 2026-03-05 10:17:37 +01:00
dependabot[bot] 3a571b7aa7 chore(deps): Bump actions/download-artifact from 7.0.0 to 8.0.0 (#81)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7.0.0 to 8.0.0.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/37930b1c2abaa49bbe596cd826c3c89aef350131...70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 13:50:01 +01:00
dependabot[bot] 962de095c0 chore(deps): Bump taiki-e/install-action from 2.68.5 to 2.68.13 (#82)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.68.5 to 2.68.13.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/71b48393496777ee11188c07a34d48b048a985cd...a3324fb0eb94b8230ec968c3389c1b7929fc2f3b)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.68.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 13:48:30 +01:00
dependabot[bot] 954d950c2f chore(deps): Bump actions/upload-artifact from 6.0.0 to 7.0.0 (#80)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 13:47:50 +01:00
Jagoda Estera Ślązak f149797775 chore(ci): Fix binary publish job (#79) 2026-02-28 18:30:47 +01:00
Jagoda Estera Ślązak 3054b31dc7 chore(release): prepare for 0.5.2 (#78)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-27 18:02:11 +01:00
Jagoda Estera Ślązak edca7cbf2a chore(ci): Add missing Zig dependency (#77)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-27 17:46:16 +01:00
Jagoda Estera ?l?zak 6bb90f8811 chore(ci): Build and upload binaries in CI (#76)
Use zigbuild directly instead of cargo-dist,
as cargo-dist doesn't suit our needs well
(e.g. uploading binaries at top level).

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-27 10:54:14 +01:00
Jagoda Estera Ślązak e62b161848 chore: Bump cargo-dist (#73)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-25 16:24:58 +01:00
Jagoda Estera Ślązak 82618de311 feat: Check incoming email return address (#72)
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.

Closes #67

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-25 16:20:08 +01:00
Jagoda Estera Ślązak a5c6880ceb refactor: Check if email is encrypted before verifying DKIM (#71)
This way we won't be logging DKIM rejections on messages that
would be rejected anyway due to not being encrypted.

Fixes #69

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-25 16:10:01 +01:00
Jagoda Estera Ślązak 2e1c38466c fix(logs): Log correct address for outbound messages (#70)
Fixes #68

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-25 16:08:41 +01:00
Jagoda Estera Ślązak ff3ffd54ef chore(release): prepare for 0.5.1 (#65)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-24 12:04:43 +01:00
Jagoda Estera Ślązak e1841cb590 fix(logs): Log From address instead of envelope MAIL FROM. (#66)
Fixes #64

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-24 11:58:28 +01:00
Jagoda Estera Ślązak 52208c42b6 fix(dkim): Accept TXT records with escaped quotes (#61) 2026-02-24 08:12:17 +01:00
Jagoda Estera Ślązak 8a1684d488 fix(smtp): Properly handle bounce messages (#63)
Closes #58

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-23 19:45:21 +01:00
dependabot[bot] 38627bfb4b chore(deps): Bump taiki-e/install-action from 2.67.30 to 2.68.5 (#57)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.67.30 to 2.68.5.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/288875dd3d64326724fa6d9593062d9f8ba0b131...71b48393496777ee11188c07a34d48b048a985cd)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.68.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 19:45:01 +01:00
Jagoda Estera Ślązak 6145be4108 fix(dkim): Accept TXT records with no v= tag (#62)
Cache at most 3 TXT records instead of trying to find one that includes `DKIM`.

Closes #59

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-23 19:44:30 +01:00
Jagoda Estera Ślązak 288afa3273 chore(release): prepare for 0.5.0 (#56)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-20 18:12:35 +01:00
Jagoda Estera Ślązak 54982b1fdd feat: Save rejected messages to /tmp (#55) 2026-02-20 15:38:46 +01:00
l 94f1b29917 refactor: do not copy the mail in memory for DKIM verification (#54)
`String::from_utf8_lossy` always copies the data
for the case when it needs to replace invalid UTF-8,
but we don't want invalid UTF-8 anyway as replacing
invalid UTF-8 characters will break DKIM signature.
2026-02-19 19:06:08 +01:00
Jagoda Estera Ślązak 7d4fac698f fix(dkim): Make simple header canonicalization work properly (#53)
Previously header parsing caused leading whitespaces to be
trimmed in HeaderBody, which resulted in incorrect simple
header canonicalization.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-19 14:33:40 +01:00
Jagoda Estera Ślązak 083ec1a60b chore(release): prepare for 0.4.1 (#51)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-17 13:07:46 +01:00
Jagoda Estera Ślązak 6e2a37956c chore(tests): Add a way to disable DKIM for tests (#50)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-17 13:00:31 +01:00
Jagoda Estera Ślązak 4eb7e9fd63 chore(release): prepare for 0.4.0 (#49)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-17 10:38:31 +01:00
Jagoda Estera Ślązak 4837754245 feat: Support addresses using domain literals (#42)
If the incoming email comes from address that uses
domain literals `[<ipv4>]` or `[IPv6:<ipv6>]`,
skip DKIM verification and instead check IP
alignment with originating IP from XFORWARD
command.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-16 18:25:21 +01:00
Jagoda Estera Ślązak a096d0550f feat!: DKIM verifier (#35)
* feat!: DKIM verifier

This implements a DKIM verification as well as a strict
DKIM signature alignment check with domain in `From`
header address.

Caches the retrieved RDATA using in-memory LRU.

BREAKING CHANGE: incoming messages now require DKIM signatures
  aligned to domain of the `From` header address.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>

* chore(license): License binaries under GPLv3

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>

---------

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-16 09:50:55 +01:00
Jagoda Estera Ślązak 3bf8395ef6 chore(release): prepare for 0.3.0 (#45)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-14 14:42:55 +01:00
dependabot[bot] 85d6c381b3 chore(deps): Bump dtolnay/rust-toolchain (#46)
Bumps [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 to efa25f7f19611383d5b0ccf2d1c8914531636bf9.
- [Release notes](https://github.com/dtolnay/rust-toolchain/releases)
- [Commits](https://github.com/dtolnay/rust-toolchain/compare/f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561...efa25f7f19611383d5b0ccf2d1c8914531636bf9)

---
updated-dependencies:
- dependency-name: dtolnay/rust-toolchain
  dependency-version: efa25f7f19611383d5b0ccf2d1c8914531636bf9
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-14 14:36:27 +01:00
dependabot[bot] 21f0614b5d chore(deps): Bump taiki-e/install-action from 2.67.24 to 2.67.30 (#48)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.67.24 to 2.67.30.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/4573997e3b6c0fbc22f7acab193b00a8a3ca817d...288875dd3d64326724fa6d9593062d9f8ba0b131)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.67.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-14 14:35:33 +01:00
dependabot[bot] 10fd148155 chore(deps): Bump env_logger from 0.11.8 to 0.11.9 (#47)
Bumps [env_logger](https://github.com/rust-cli/env_logger) from 0.11.8 to 0.11.9.
- [Release notes](https://github.com/rust-cli/env_logger/releases)
- [Changelog](https://github.com/rust-cli/env_logger/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-cli/env_logger/compare/v0.11.8...v0.11.9)

---
updated-dependencies:
- dependency-name: env_logger
  dependency-version: 0.11.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-14 14:34:36 +01:00
Jagoda Estera Ślązak cc1604b0b2 feat: Support legacy, pre-OpenPGP packet format (#44)
Support legacy packet format for compatibility with GnuPG.

Closes: #43

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-13 12:31:39 +01:00
dependabot[bot] c1f728535f chore(deps): Bump taiki-e/install-action from 2.67.16 to 2.67.24 (#37)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.67.16 to 2.67.24.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/a362280ea100b5bb920cf9040c038ce9a50c7943...4573997e3b6c0fbc22f7acab193b00a8a3ca817d)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.67.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 11:29:31 +01:00
Jagoda Estera Ślązak 0ee1e9924c refactor: Use a custom, minimal SMTP client instead of lettre (#33)
This disables the Nagle's algorithm on re-insertion.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-06 11:15:14 +01:00
Jagoda Estera Ślązak 8d18b73681 refactor: Remove unnecessary Arc (#36)
`Arc` around handler configs are not needed,
as they are not shared.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-05 16:42:06 +01:00
dependabot[bot] 47f16360a4 chore(deps): Bump taiki-e/install-action from 2.67.6 to 2.67.16 (#34)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.67.6 to 2.67.16.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/8444b4f2165ec93147f3ee41e06495653063ad11...a362280ea100b5bb920cf9040c038ce9a50c7943)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.67.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-02 11:05:48 +01:00
Jagoda Estera Ślązak 7be2004051 chore(dist): Switch to musl targets (#31)
Ensures filtermail binaries are 100% statically linked.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-28 20:06:04 +01:00
Jagoda Estera Ślązak 95fd106e43 chore(release): prepare for 0.2.0 (#30)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-28 18:22:53 +01:00
Jagoda Estera Ślązak 23231d462c feat: Configurable rate limiter max burst size (#28)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-28 17:47:48 +01:00
dependabot[bot] 9a8e4fe65c chore(deps): Bump taiki-e/install-action from 2.66.7 to 2.67.6 (#24)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.66.7 to 2.67.6.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/542cebaaed782771e619bd5609d97659d109c492...8444b4f2165ec93147f3ee41e06495653063ad11)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.67.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-27 10:07:52 +01:00
dependabot[bot] c0104152e8 chore(deps): Bump actions/checkout from 6.0.1 to 6.0.2 (#25)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/8e8c483db84b4bee98b60c0593521ed34d9990e8...de0fac2e4500dabe0009e67214ff5f5447ce83dd)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-27 10:06:35 +01:00
link2xt aa5999cc25 refactor: remove Mutex around rate limiter
Rate limiter handles all the necessary locking internally.
2026-01-26 17:59:12 +00:00
link2xt 3be4c4d5ee perf: disable Nagle's algorithm and do own buffering on server connections
I don't know if Postfix as the client disables delayed ACKs,
but if delayed ACKs are enabled, Nagle's algorithm introduces unnecessary delays.
2026-01-26 14:56:36 +00:00
Jagoda Estera Ślązak 0990a23700 chore(release): prepare for 0.1.2 (#22)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-22 15:24:33 +01:00
Jagoda Estera Ślązak cab16fc8f1 fix: Make inbound/outbound log messages consistent (#23)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-22 15:14:28 +01:00
Jagoda Estera Ślązak ca6ccd9acf fix: Set logs required by grafana to INFO (#21)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-22 14:49:55 +01:00
Jagoda Estera Ślązak b5a56a6a4e perf: Use governor for rate limiting (#20)
Fixes: #19

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-22 14:49:26 +01:00
Jagoda Ślązak 06b7b0ca6d chore(release): prepare for 0.1.1
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-21 17:22:19 +01:00
Jagoda Ślązak e214a74252 chore: Configure git-cliff
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-21 17:22:19 +01:00
Jagoda Estera Ślązak 9eb86b23ce refactor: Apply more lints (#17)
Sets lint rules and applies required changes.

Fixes: #16

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-21 16:28:24 +01:00
Jagoda Estera Ślązak aab39be662 feat: Improve logging (#13)
Improves logs and reduces verbosity.

Fixes: #8

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-21 14:35:52 +01:00
Jagoda Estera Ślązak 2b4205a60a fix(config): Set default values for internal SMTP ports and max message size (#12)
Fixes: #7

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-21 14:27:21 +01:00
Jagoda Estera Ślązak 41e9e9f6d0 chore(dist): Configure cargo-dist (#10)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-21 14:26:37 +01:00
Jagoda Estera Ślązak c010d999f5 fix: Correct a typo in SMTP answer (#11)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-21 14:26:20 +01:00
l f2f7767b71 refactor: get rid of indexing and slicing in check_armored_payload() (#15)
Indexing can potentially panic, without indexing
it is easier to make sure there will be no crash.
2026-01-21 14:25:40 +01:00
Jagoda Estera Ślązak 353f1dc77d fix: Improve address extraction from SMTP commands (#14)
Prevents extraction failing on SRS.

Fixes: #9

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-21 14:06:20 +01:00
dependabot[bot] 33e975ade3 chore(deps): Bump dtolnay/rust-toolchain (#4)
Bumps [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from 6d653acede28d24f02e3cd41383119e8b1b35921 to f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561.
- [Release notes](https://github.com/dtolnay/rust-toolchain/releases)
- [Commits](https://github.com/dtolnay/rust-toolchain/compare/6d653acede28d24f02e3cd41383119e8b1b35921...f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561)

---
updated-dependencies:
- dependency-name: dtolnay/rust-toolchain
  dependency-version: f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 16:59:22 +01:00
dependabot[bot] 86f86515be chore(deps): Bump Swatinem/rust-cache from 2.8.1 to 2.8.2 (#5)
Bumps [Swatinem/rust-cache](https://github.com/swatinem/rust-cache) from 2.8.1 to 2.8.2.
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/f13886b937689c021905a6b90929199931d60db1...779680da715d629ac1d338a641029a2f4372abb5)

---
updated-dependencies:
- dependency-name: Swatinem/rust-cache
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 16:56:49 +01:00
dependabot[bot] c15f18685a chore(deps): Bump thiserror from 2.0.17 to 2.0.18 (#3)
Bumps [thiserror](https://github.com/dtolnay/thiserror) from 2.0.17 to 2.0.18.
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/2.0.17...2.0.18)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-version: 2.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 16:55:24 +01:00
dependabot[bot] 00fb3d8156 chore(deps): Bump actions/checkout from 5.0.0 to 6.0.1 (#6)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5.0.0 to 6.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/08c6903cd8c0fde910a37f88322edcfb5dd907a8...8e8c483db84b4bee98b60c0593521ed34d9990e8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 16:53:52 +01:00
dependabot[bot] c78df0009b chore(deps): Bump taiki-e/install-action from 2.62.49 to 2.66.7 (#2)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.62.49 to 2.66.7.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/44c6d64aa62cd779e873306675c7a58e86d6d532...542cebaaed782771e619bd5609d97659d109c492)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.66.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 16:53:01 +01:00
Jagoda Ślązak d2cca26b1c chore(cargo): Add metadata
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-19 16:49:50 +01:00
Jagoda Ślązak d25faf4fdf docs(license): Add LICENSE
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-19 16:49:50 +01:00
Jagoda Ślązak 392def1322 docs(readme): Add README.md
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-19 16:49:50 +01:00
Jagoda Ślązak 401fd39ec0 chore(dependabot): Add github-actions to dependabot
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-19 16:49:50 +01:00
Jagoda Ślązak f9abff0764 chore(ci): Setup CI
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-19 16:49:50 +01:00
Jagoda Ślązak 5f4399f54d chore(dependabot): Setup dependabot
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-19 16:49:50 +01:00
Jagoda Ślązak 6340f479ae feat: Initial implementation
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-19 16:49:50 +01:00
Jagoda Ślązak 4185ebe3c5 chore: Init repository
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-14 18:12:22 +01:00
48 changed files with 9869 additions and 0 deletions
+15
View File
@@ -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
+107
View File
@@ -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
+69
View File
@@ -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/*
+1
View File
@@ -78,6 +78,7 @@ docs/_build/
# PyBuilder
.pybuilder/
target/
filtermail/target/
# Jupyter Notebook
.ipynb_checkpoints
+291
View File
@@ -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
+3109
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -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"
+21
View File
@@ -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.
+674
View File
@@ -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>.
+203
View File
@@ -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).
+94
View File
@@ -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
+101
View File
@@ -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"]++
}
+143
View File
@@ -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,
}
}
}
+298
View File
@@ -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);
}
}
+83
View File
@@ -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")
}
}
+169
View File
@@ -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)
}
}
+222
View File
@@ -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?)
}
}
+199
View File
@@ -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(())
}
+174
View File
@@ -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(())
}
}
+350
View File
@@ -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);
}
}
+217
View File
@@ -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())
}
}
+534
View File
@@ -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);
}
}
+7
View File
@@ -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";
+381
View File
@@ -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
@@ -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
+59
View File
@@ -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
}
}
+127
View File
@@ -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))
}
}
+57
View File
@@ -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)
}
+49
View File
@@ -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()
}
}
+502
View File
@@ -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(())
}
}
+57
View File
@@ -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,
})
}
}
+515
View File
@@ -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,
)
}
}
+198
View File
@@ -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);
}
}
+56
View File
@@ -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--
+47
View File
@@ -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!
+80
View File
@@ -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--
+66
View File
@@ -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--
+66
View File
@@ -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--
+66
View File
@@ -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--
+25
View File
@@ -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-----
+44
View File
@@ -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--
+46
View File
@@ -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--
+33
View File
@@ -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--
+23
View File
@@ -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--
+21
View File
@@ -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--