Merge branch 'master' into synth-relay
This commit is contained in:
commit
c9d7dbbc25
15 changed files with 325 additions and 207 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
@ -1,11 +1,23 @@
|
|||
# SpamAssassin Milter changelog
|
||||
|
||||
## 0.1.7 (unreleased)
|
||||
## 0.2.1 (unreleased)
|
||||
|
||||
* Add `--synth-relay` option to allow inserting the synthesised internal relay
|
||||
header (the MTA’s `Received` header) at a different position than the at the
|
||||
very beginning.
|
||||
|
||||
## 0.2.0 (2021-08-26)
|
||||
|
||||
* Bump minimum supported Rust version to 1.46.0.
|
||||
* (defaults change) Invoke `spamc` using the absolute path `/usr/bin/spamc`
|
||||
(instead of any executable named `spamc` in the search path). To customise
|
||||
this, set the environment variable `SPAMASSASSIN_MILTER_SPAMC` to the
|
||||
desired path when building the application.
|
||||
* Revise header rewriting logic. Handling and placement of `X-Spam-` headers
|
||||
now more accurately mirrors that applied by SpamAssassin.
|
||||
* Include authentication status in information passed on to SpamAssassin.
|
||||
* Update dependencies.
|
||||
|
||||
## 0.1.6 (2021-05-17)
|
||||
|
||||
* Improve processing of incoming `X-Spam-Flag` headers. Previously, in rare
|
||||
|
|
32
Cargo.lock
generated
32
Cargo.lock
generated
|
@ -1,5 +1,7 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "0.7.18"
|
||||
|
@ -37,9 +39,9 @@ checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
|
|||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.2.1"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
|
@ -71,24 +73,24 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.1.18"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c"
|
||||
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.3.0"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47be2f14c678be2fdcab04ab1171db51b2762ce6f0a8ee87c8dd4a04ed216135"
|
||||
checksum = "68f2d64f2edebec4ce84ad108148e67e1064789bee435edc5b60ad398714a3a9"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.94"
|
||||
version = "0.2.99"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18794a8ad5b29321f790b55d93dfba91e125cb1a9edbd4f8e3150acc771c1a5e"
|
||||
checksum = "a7f823d141fe0a24df1e23b4af4e3c7ba9e5966ec514ea068c93024aa7deb765"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
|
@ -151,9 +153,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.7.2"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3"
|
||||
checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
|
@ -163,9 +165,9 @@ checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c"
|
|||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.26"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a152013215dca273577e18d2bf00fa862b89b24169fb78c4c95aeb07992c9cec"
|
||||
checksum = "5c7ed8b8c7b886ea3ed7dde405212185f423ab44682667c8c6dd14aa1d9f6612"
|
||||
dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
@ -198,7 +200,7 @@ checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
|
|||
|
||||
[[package]]
|
||||
name = "spamassassin-milter"
|
||||
version = "0.1.6"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
@ -217,9 +219,9 @@ checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
|
|||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.72"
|
||||
version = "1.0.74"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1e8cdbefb79a9a5a65e0db8b47b723ee907b7c7f8496c76a1770b5c310bab82"
|
||||
checksum = "1873d832550d4588c3dbc20f01361ab00bfe741048f71e3fecf145a7cc18b29c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
[package]
|
||||
name = "spamassassin-milter"
|
||||
version = "0.1.6" # remember to update html_root_url and VERSION
|
||||
version = "0.2.0"
|
||||
edition = "2018"
|
||||
description = "Milter for spam filtering with SpamAssassin"
|
||||
authors = ["David Bürgin <dbuergin@gluet.ch>"]
|
||||
license = "GPL-3.0-or-later"
|
||||
categories = ["email"]
|
||||
keywords = ["email", "milter", "spam", "spamassassin"]
|
||||
|
|
73
README.md
73
README.md
|
@ -37,46 +37,46 @@ Postfix, and for delivery [Dovecot] with LMTP and the [Sieve plugin].
|
|||
[Dovecot]: https://dovecot.org
|
||||
[Sieve plugin]: https://doc.dovecot.org/configuration_manual/sieve
|
||||
|
||||
## Building
|
||||
|
||||
This project is a [Rust] package. Build it with Cargo as usual.
|
||||
|
||||
The program `spamc`, which is used for communication with SpamAssassin server,
|
||||
must be executable and located in the milter’s search path.
|
||||
|
||||
As a milter, this package requires the libmilter C library to be available. Be
|
||||
sure to install the libmilter shared library and header files.
|
||||
|
||||
The shared library is discovered using the pkg-config program. If your
|
||||
distribution does not install a pkg-config metadata file for libmilter, try
|
||||
using the provided `milter.pc` file. Put it on the pkg-config path as follows:
|
||||
|
||||
```
|
||||
PKG_CONFIG_PATH=. cargo build
|
||||
```
|
||||
|
||||
The integration tests rely on the `miltertest` utility. Make sure `miltertest`
|
||||
is available and can be executed when running the integration tests. (Until
|
||||
recently, `miltertest` had a serious bug that prevents most integration tests in
|
||||
this package from completing; make sure you use an up-to-date version of
|
||||
`miltertest`.)
|
||||
|
||||
The minimum supported Rust version is 1.42.0.
|
||||
|
||||
[Rust]: https://www.rust-lang.org
|
||||
|
||||
## Installation
|
||||
|
||||
SpamAssassin Milter can be installed using Cargo. The program will be installed
|
||||
in the local installation’s `bin` directory as usual:
|
||||
SpamAssassin Milter is a [Rust] project. It can be installed using Cargo as
|
||||
usual:
|
||||
|
||||
```
|
||||
cargo install --locked spamassassin-milter
|
||||
```
|
||||
|
||||
Again, if your distribution does not provide pkg-config metadata, try using the
|
||||
`milter.pc` file included in this package. Save `milter.pc` to some directory,
|
||||
then run the above install command with `PKG_CONFIG_PATH` set to that directory.
|
||||
As a milter, this package requires the libmilter C library to be available. Be
|
||||
sure to install the libmilter shared library provided by your distribution.
|
||||
|
||||
The shared library is discovered using the pkg-config program. If your
|
||||
distribution does not install pkg-config metadata for libmilter, try using the
|
||||
provided `milter.pc` file. Put this file on the pkg-config path when running any
|
||||
Cargo command:
|
||||
|
||||
```
|
||||
PKG_CONFIG_PATH=. cargo build
|
||||
```
|
||||
|
||||
SpamAssassin Milter uses the `spamc` program for communication with SpamAssassin
|
||||
server. By default, `/usr/bin/spamc` is used as the executable. To override
|
||||
this, set the environment variable `SPAMASSASSIN_MILTER_SPAMC` to the desired
|
||||
path when building the application.
|
||||
|
||||
The minimum supported Rust version is 1.46.0.
|
||||
|
||||
[Rust]: https://www.rust-lang.org
|
||||
|
||||
### Building
|
||||
|
||||
The prerequisites mentioned above apply to building and testing the package,
|
||||
too.
|
||||
|
||||
Additionally, the integration tests rely on the third-party `miltertest`
|
||||
utility. Make sure `miltertest` is available and can be executed when running
|
||||
the integration tests. (Until recently, `miltertest` had a serious bug that
|
||||
prevents most integration tests in this package from completing; make sure you
|
||||
use an up-to-date version of `miltertest`.)
|
||||
|
||||
## Usage
|
||||
|
||||
|
@ -106,7 +106,8 @@ passing the file’s path to `man`: `man ./spamassassin-milter.8`)
|
|||
Setting up SpamAssassin Milter as a system service is easiest by using the
|
||||
provided systemd service file: Edit `spamassassin-milter.service` with the
|
||||
desired port, install it in `/etc/systemd/system`, then enable and start the
|
||||
service.
|
||||
service. Where necessary, user, group, and umask can also be customised in this
|
||||
file.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
@ -238,8 +239,8 @@ submitted via a SASL-authenticated channel).
|
|||
|
||||
A further component that may be useful with SpamAssassin Milter is a
|
||||
[Sieve]-capable mail delivery service. A Sieve script may for example look at
|
||||
the `X-Spam-` SpamAssassin headers of the incoming message, and take action
|
||||
based on those.
|
||||
SpamAssassin’s `X-Spam-` headers in the incoming message, and take action based
|
||||
on those.
|
||||
|
||||
As an example, in case [Dovecot] does mail delivery using [LMTP], enable the
|
||||
Sieve plugin for the LMTP protocol, and then set up a global Sieve script that
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
.TH SPAMASSASSIN-MILTER 8 2021-05-17
|
||||
.TH SPAMASSASSIN-MILTER 8 2021-08-26
|
||||
.SH NAME
|
||||
spamassassin-milter \- milter for spam filtering with SpamAssassin
|
||||
.SH SYNOPSIS
|
||||
|
@ -57,7 +57,7 @@ received from SpamAssassin server.
|
|||
is a light-weight integration component, enabling use of SpamAssassin with a
|
||||
milter-capable MTA.
|
||||
Users are advised to familiarize themselves with the setup and configuration
|
||||
options of the components participating, namely, the SpamAssassin programs
|
||||
options of the components involved, namely, the SpamAssassin programs
|
||||
.B spamd
|
||||
(SpamAssassin server) and
|
||||
.BR spamc ,
|
||||
|
|
|
@ -55,10 +55,7 @@ fn handle_negotiate(
|
|||
|
||||
context.api.request_macros(Stage::Connect, "")?;
|
||||
context.api.request_macros(Stage::Helo, "")?;
|
||||
context.api.request_macros(
|
||||
Stage::Mail,
|
||||
if config::get().auth_untrusted() { "" } else { "{auth_authen}" },
|
||||
)?;
|
||||
context.api.request_macros(Stage::Mail, "{auth_authen}")?;
|
||||
context.api.request_macros(Stage::Rcpt, "")?;
|
||||
context.api.request_macros(Stage::Data, "i j _ {tls_version} v")?;
|
||||
context.api.request_macros(Stage::Eoh, "")?;
|
||||
|
@ -214,6 +211,7 @@ fn synthesize_relay_header(api: &impl MacroValue, client: &mut Client) -> milter
|
|||
.and_then(|v| v.split_ascii_whitespace().next())
|
||||
.unwrap_or("Postfix"),
|
||||
api.macro_value("{tls_version}")?.is_some(),
|
||||
api.macro_value("{auth_authen}")?.is_some(),
|
||||
id,
|
||||
&Local::now().to_rfc2822(),
|
||||
)?;
|
||||
|
|
|
@ -27,7 +27,10 @@ pub struct Spamc {
|
|||
}
|
||||
|
||||
impl Spamc {
|
||||
const SPAMC_PROGRAM: &'static str = "spamc";
|
||||
const SPAMC_PROGRAM: &'static str = match option_env!("SPAMASSASSIN_MILTER_SPAMC") {
|
||||
Some(p) => p,
|
||||
None => "/usr/bin/spamc",
|
||||
};
|
||||
|
||||
pub fn new(spamc_args: &'static [String]) -> Self {
|
||||
Self {
|
||||
|
@ -42,7 +45,7 @@ impl Process for Spamc {
|
|||
fn connect(&mut self) -> Result<()> {
|
||||
// `Command::spawn` always succeeds when `spamc` can be invoked, even if
|
||||
// logically the command is invalid, eg if it uses non-existing options.
|
||||
let mut spamc = Command::new(Spamc::SPAMC_PROGRAM)
|
||||
let mut spamc = Command::new(Self::SPAMC_PROGRAM)
|
||||
.args(self.spamc_args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
|
@ -200,24 +203,30 @@ impl Client {
|
|||
my_hostname: &str,
|
||||
mta: &str,
|
||||
tls: bool,
|
||||
auth: bool,
|
||||
queue_id: &str,
|
||||
date_time: &str,
|
||||
) -> Result<()> {
|
||||
// Sending this ‘Received’ header is crucial: Milters don’t see the
|
||||
// MTA’s own ‘Received’ header. However, SpamAssassin draws a lot of
|
||||
// information from that header. So we make one up and send it along.
|
||||
|
||||
let client_ip = self.client_ip.to_string();
|
||||
|
||||
let buf = format!(
|
||||
"Received: from {} ({})\r\n\
|
||||
\tby {} ({}) with {} id {};\r\n\
|
||||
\t{}\r\n\
|
||||
\t(envelope-from {})\r\n",
|
||||
self.helo_host.as_ref().unwrap_or(&client_ip),
|
||||
client_name_addr.unwrap_or(&client_ip),
|
||||
my_hostname,
|
||||
mta,
|
||||
if tls { "ESMTPS" } else { "ESMTP" },
|
||||
queue_id,
|
||||
date_time,
|
||||
self.sender
|
||||
"Received: from {helo} ({client})\r\n\
|
||||
\tby {hostname} ({mta}) with ESMTP{tls}{auth} id {id};\r\n\
|
||||
\t{date_time}\r\n\
|
||||
\t(envelope-from {sender})\r\n",
|
||||
helo = self.helo_host.as_ref().unwrap_or(&client_ip),
|
||||
client = client_name_addr.unwrap_or(&client_ip),
|
||||
hostname = my_hostname,
|
||||
mta = mta,
|
||||
tls = if tls { "S" } else { "" },
|
||||
auth = if auth { "A" } else { "" },
|
||||
id = queue_id,
|
||||
date_time = date_time,
|
||||
sender = self.sender
|
||||
);
|
||||
|
||||
self.process.writer().write_all(buf.as_bytes())?;
|
||||
|
@ -360,7 +369,7 @@ fn replace_body(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{cell::RefCell, collections::HashSet, net::Ipv4Addr};
|
||||
use std::{cell::RefCell, net::Ipv4Addr};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
struct MockSpamc {
|
||||
|
@ -407,6 +416,7 @@ mod tests {
|
|||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
enum Action {
|
||||
AddHeader(String, String),
|
||||
InsertHeader(usize, String, String),
|
||||
ReplaceHeader(String, usize, Option<String>),
|
||||
AppendBodyChunk(Vec<u8>),
|
||||
SetErrorReply(String, Option<String>, Vec<String>),
|
||||
|
@ -430,6 +440,12 @@ mod tests {
|
|||
))
|
||||
}
|
||||
|
||||
fn insert_header(&self, index: usize, name: &str, value: &str) -> milter::Result<()> {
|
||||
Ok(self.called.borrow_mut().push(
|
||||
Action::InsertHeader(index, name.to_owned(), value.to_owned())
|
||||
))
|
||||
}
|
||||
|
||||
fn replace_header(&self, name: &str, index: usize, value: Option<&str>) -> milter::Result<()> {
|
||||
Ok(self.called.borrow_mut().push(
|
||||
Action::ReplaceHeader(name.to_owned(), index, value.map(|v| v.to_owned()))
|
||||
|
@ -454,10 +470,6 @@ mod tests {
|
|||
unimplemented!();
|
||||
}
|
||||
|
||||
fn insert_header(&self, _: usize, _: &str, _: &str) -> milter::Result<()> {
|
||||
unimplemented!();
|
||||
}
|
||||
|
||||
fn quarantine(&self, _: &str) -> milter::Result<()> {
|
||||
unimplemented!();
|
||||
}
|
||||
|
@ -498,7 +510,7 @@ mod tests {
|
|||
assert_eq!(client.bytes_written(), 48);
|
||||
assert_eq!(
|
||||
as_mock_spamc(client.process.as_ref()).buf,
|
||||
Vec::from(b"name1: value1\r\nname2: value2\r\n\tcontinued\r\n\r\nbody" as &[_])
|
||||
b"name1: value1\r\nname2: value2\r\n\tcontinued\r\n\r\nbody".as_ref()
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -519,12 +531,12 @@ mod tests {
|
|||
|
||||
assert_eq!(
|
||||
as_mock_spamc(client.process.as_ref()).buf,
|
||||
Vec::from(format!(
|
||||
format!(
|
||||
"X-Envelope-From: {}\r\n\
|
||||
X-Envelope-To: {},\r\n\
|
||||
\t{}\r\n",
|
||||
sender, recipient1, recipient2
|
||||
).as_bytes())
|
||||
).as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -552,16 +564,15 @@ mod tests {
|
|||
let status = client.process("id", &actions, &config).unwrap();
|
||||
|
||||
assert_eq!(status, Status::Reject);
|
||||
|
||||
let called = actions.called.borrow();
|
||||
assert_eq!(called.len(), 1);
|
||||
assert_eq!(
|
||||
called.first().unwrap(),
|
||||
&Action::SetErrorReply(
|
||||
"550".into(),
|
||||
Some("5.7.1".into()),
|
||||
vec!["Spam message refused".into()]
|
||||
)
|
||||
actions.called.borrow().as_slice(),
|
||||
[
|
||||
Action::SetErrorReply(
|
||||
"550".into(),
|
||||
Some("5.7.1".into()),
|
||||
vec!["Spam message refused".into()],
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -580,19 +591,16 @@ mod tests {
|
|||
let status = client.process("id", &actions, &config).unwrap();
|
||||
|
||||
assert_eq!(status, Status::Accept);
|
||||
|
||||
let called = actions.called.borrow();
|
||||
assert_eq!(called.len(), 4);
|
||||
|
||||
let mut expected = HashSet::new();
|
||||
expected.insert(Action::AddHeader("X-Spam-Flag".into(), " YES".into()));
|
||||
expected.insert(Action::ReplaceHeader("X-Spam-Level".into(), 1, Some(" *****".into())));
|
||||
expected.insert(Action::ReplaceHeader("X-Spam-Report".into(), 1, None));
|
||||
expected.insert(Action::AppendBodyChunk(b"Report".to_vec()));
|
||||
|
||||
for a in called.iter() {
|
||||
assert!(expected.contains(&a));
|
||||
}
|
||||
assert_eq!(
|
||||
actions.called.borrow().as_slice(),
|
||||
[
|
||||
Action::ReplaceHeader("X-Spam-Level".into(), 1, None),
|
||||
Action::InsertHeader(0, "X-Spam-Level".into(), " *****".into()),
|
||||
Action::InsertHeader(0, "X-Spam-Flag".into(), " YES".into()),
|
||||
Action::ReplaceHeader("x-spam-report".into(), 1, None),
|
||||
Action::AppendBodyChunk(b"Report".to_vec()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -611,7 +619,7 @@ mod tests {
|
|||
assert_eq!(status, Status::Accept);
|
||||
|
||||
let called = actions.called.borrow();
|
||||
assert!(called.contains(&Action::AddHeader("X-Spam-Level".into(), " *****".into())));
|
||||
assert!(called.contains(&Action::InsertHeader(0, "X-Spam-Level".into(), " *****".into())));
|
||||
assert!(!called.contains(&Action::AppendBodyChunk(b"Report".to_vec())));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -77,10 +77,6 @@ where
|
|||
Self { map: StrVecMap::new() }
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &E> {
|
||||
self.map.keys()
|
||||
}
|
||||
|
||||
pub fn contains<Q: AsRef<str>>(&self, key: Q) -> bool {
|
||||
self.map.contains_key(key)
|
||||
}
|
||||
|
@ -148,9 +144,8 @@ mod tests {
|
|||
assert!(set.insert("KEY2"));
|
||||
assert!(!set.insert("key1"));
|
||||
|
||||
let mut iter = set.iter();
|
||||
assert_eq!(iter.next(), Some(&"KEY1"));
|
||||
assert_eq!(iter.next(), Some(&"KEY2"));
|
||||
assert_eq!(iter.next(), None);
|
||||
assert!(set.contains("key1"));
|
||||
assert!(set.contains("key2"));
|
||||
assert!(!set.contains("key3"));
|
||||
}
|
||||
}
|
||||
|
|
236
src/email.rs
236
src/email.rs
|
@ -6,7 +6,6 @@ use crate::{
|
|||
use milter::ActionContext;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::{
|
||||
cmp,
|
||||
fmt::{self, Display, Formatter},
|
||||
str,
|
||||
};
|
||||
|
@ -51,7 +50,8 @@ fn header_lines(header: &[u8]) -> Vec<&[u8]> {
|
|||
let mut start = i;
|
||||
|
||||
while i < header.len() {
|
||||
// Assume line endings are always encoded as b"\r\n".
|
||||
// Assume line endings are always encoded as b"\r\n" since that is what
|
||||
// the client sent to SpamAssassin earlier.
|
||||
if header[i] == b'\r' && i + 1 < header.len() && header[i + 1] == b'\n' {
|
||||
if i + 2 < header.len() && (header[i + 2] == b' ' || header[i + 2] == b'\t') {
|
||||
i += 3;
|
||||
|
@ -73,7 +73,10 @@ fn header_lines(header: &[u8]) -> Vec<&[u8]> {
|
|||
}
|
||||
|
||||
fn parse_header_line(bytes: &[u8]) -> Result<Header<'_>> {
|
||||
// This assumes that headers received back from SpamAssassin are valid
|
||||
// UTF-8, which is plausible since the client only sent UTF-8 earlier.
|
||||
let line = str::from_utf8(bytes).map_err(|_| Error::ParseEmail)?;
|
||||
|
||||
let (name, value) = line.split_at(line.find(':').ok_or(Error::ParseEmail)?);
|
||||
|
||||
if name.trim().is_empty() {
|
||||
|
@ -87,11 +90,8 @@ fn parse_header_line(bytes: &[u8]) -> Result<Header<'_>> {
|
|||
|
||||
pub fn ensure_crlf(s: &str) -> String {
|
||||
// For symmetry, ensure existing occurrences of "\r\n" remain unchanged.
|
||||
s.split('\n')
|
||||
.map(|line| match line.as_bytes().last() {
|
||||
Some(&last) if last == b'\r' => &line[..(line.len() - 1)],
|
||||
_ => line,
|
||||
})
|
||||
s.split("\r\n")
|
||||
.flat_map(|s| s.split('\n'))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n")
|
||||
}
|
||||
|
@ -101,27 +101,14 @@ pub fn ensure_lf(s: &str) -> String {
|
|||
}
|
||||
|
||||
pub fn is_spam_assassin_header(name: &str) -> bool {
|
||||
let prefix = b"X-Spam-";
|
||||
let name = name.as_bytes();
|
||||
|
||||
name[..cmp::min(prefix.len(), name.len())].eq_ignore_ascii_case(prefix)
|
||||
let prefix = "X-Spam-";
|
||||
matches!(name.get(..prefix.len()), Some(s) if s.eq_ignore_ascii_case(prefix))
|
||||
}
|
||||
|
||||
// Values use CRLF line breaks and include leading whitespace.
|
||||
pub type HeaderMap = StrVecMap<String, String>;
|
||||
pub type HeaderSet<'e> = StrVecSet<&'e str>;
|
||||
|
||||
// Selected subset of ‘X-Spam-’ headers for which we assume responsibility.
|
||||
pub static SPAM_ASSASSIN_HEADERS: Lazy<HeaderSet<'static>> = Lazy::new(|| {
|
||||
let mut h = HeaderSet::new();
|
||||
h.insert("X-Spam-Checker-Version");
|
||||
h.insert("X-Spam-Flag");
|
||||
h.insert("X-Spam-Level");
|
||||
h.insert("X-Spam-Status");
|
||||
h.insert("X-Spam-Report");
|
||||
h
|
||||
});
|
||||
|
||||
pub static REWRITE_HEADERS: Lazy<HeaderSet<'static>> = Lazy::new(|| {
|
||||
let mut h = HeaderSet::new();
|
||||
h.insert("Subject");
|
||||
|
@ -142,21 +129,23 @@ pub static REPORT_HEADERS: Lazy<HeaderSet<'static>> = Lazy::new(|| {
|
|||
/// headers. The rewriter operates only on the first occurrence of headers with
|
||||
/// the same name.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HeaderRewriter<'a> {
|
||||
pub struct HeaderRewriter<'a, 'c> {
|
||||
original: HeaderMap,
|
||||
processed: HeaderSet<'a>,
|
||||
prepend: Option<bool>,
|
||||
spam_flag: Option<bool>,
|
||||
spam_assassin_mods: Vec<HeaderMod<'a>>,
|
||||
rewrite_mods: Vec<HeaderMod<'a>>,
|
||||
report_mods: Vec<HeaderMod<'a>>,
|
||||
config: &'a Config,
|
||||
config: &'c Config,
|
||||
}
|
||||
|
||||
impl<'a> HeaderRewriter<'a> {
|
||||
pub fn new(original: HeaderMap, config: &'a Config) -> Self {
|
||||
impl<'a, 'c> HeaderRewriter<'a, 'c> {
|
||||
pub fn new(original: HeaderMap, config: &'c Config) -> Self {
|
||||
Self {
|
||||
original,
|
||||
processed: HeaderSet::new(),
|
||||
prepend: None,
|
||||
spam_flag: None,
|
||||
spam_assassin_mods: vec![],
|
||||
rewrite_mods: vec![],
|
||||
|
@ -166,13 +155,16 @@ impl<'a> HeaderRewriter<'a> {
|
|||
}
|
||||
|
||||
pub fn process_header(&mut self, name: &'a str, value: &'a str) {
|
||||
// Assumes that the value is normalised to using CRLF line breaks, and
|
||||
// includes leading whitespace.
|
||||
// The very first header seen determines if ‘X-Spam-’ headers are
|
||||
// prepended or appended to the existing header.
|
||||
self.prepend.get_or_insert_with(|| is_spam_assassin_header(name));
|
||||
|
||||
if name.eq_ignore_ascii_case("X-Spam-Flag") {
|
||||
self.spam_flag.get_or_insert_with(|| value.trim().eq_ignore_ascii_case("YES"));
|
||||
}
|
||||
|
||||
if is_spam_assassin_header(name) {
|
||||
if let Some(m) = self.convert_to_header_mod(name, value) {
|
||||
if let Some(m) = self.convert_to_x_spam_header_mod(name, value) {
|
||||
self.spam_assassin_mods.push(m);
|
||||
}
|
||||
} else if REWRITE_HEADERS.contains(name) {
|
||||
|
@ -186,16 +178,47 @@ impl<'a> HeaderRewriter<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
fn convert_to_x_spam_header_mod(
|
||||
&mut self,
|
||||
name: &'a str,
|
||||
value: &'a str,
|
||||
) -> Option<HeaderMod<'a>> {
|
||||
if !self.processed.insert(name) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prepend = self.prepend.unwrap();
|
||||
|
||||
match self.original.get(name) {
|
||||
None => Some(HeaderMod::Add { name, value, prepend }),
|
||||
Some(original_value) => {
|
||||
// Special case ‘X-Spam-Prev-’ headers: if already present,
|
||||
// modify them in place or leave them be. Other ‘X-Spam-’
|
||||
// headers are replaced, ie stripped and re-added.
|
||||
let prev = "X-Spam-Prev-";
|
||||
if matches!(name.get(..prev.len()), Some(s) if s.eq_ignore_ascii_case(prev)) {
|
||||
if original_value != value {
|
||||
Some(HeaderMod::Modify { name, value })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(HeaderMod::Replace { name, value, prepend })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_to_header_mod(&mut self, name: &'a str, value: &'a str) -> Option<HeaderMod<'a>> {
|
||||
if !self.processed.insert(name) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match self.original.get(name) {
|
||||
None => Some(HeaderMod::Add { name, value }),
|
||||
None => Some(HeaderMod::Add { name, value, prepend: false }),
|
||||
Some(original_value) => {
|
||||
if original_value != value {
|
||||
Some(HeaderMod::Replace { name, value })
|
||||
Some(HeaderMod::Modify { name, value })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
@ -212,12 +235,18 @@ impl<'a> HeaderRewriter<'a> {
|
|||
id: &str,
|
||||
actions: &impl ActionContext,
|
||||
) -> milter::Result<()> {
|
||||
execute_mods(id, self.spam_assassin_mods.iter(), actions, self.config)?;
|
||||
if self.prepend.unwrap_or(false) {
|
||||
// Prepend ‘X-Spam-’ headers in reverse order, so that they appear
|
||||
// in the order received from SpamAssassin.
|
||||
execute_mods(id, self.spam_assassin_mods.iter().rev(), actions, self.config)?;
|
||||
} else {
|
||||
execute_mods(id, self.spam_assassin_mods.iter(), actions, self.config)?;
|
||||
}
|
||||
|
||||
// Delete certain incoming SpamAssassin headers not returned by
|
||||
// SpamAssassin, to get rid of foreign `X-Spam-Flag` etc. headers.
|
||||
let deletions = SPAM_ASSASSIN_HEADERS.iter()
|
||||
.filter(|n| self.original.contains_key(n) && !self.processed.contains(n))
|
||||
// Delete all incoming ‘X-Spam-’ headers not returned by SpamAssassin to
|
||||
// get rid of foreign ‘X-Spam-Flag’ etc. headers.
|
||||
let deletions = self.original.keys()
|
||||
.filter(|n| is_spam_assassin_header(n) && !self.processed.contains(n))
|
||||
.map(|name| HeaderMod::Delete { name })
|
||||
.collect::<Vec<_>>();
|
||||
execute_mods(id, deletions.iter(), actions, self.config)
|
||||
|
@ -277,10 +306,11 @@ pub fn replace_body(
|
|||
|
||||
/// A header rewriting modification operation. These are intended to operate
|
||||
/// only on the first instance of headers occurring multiple times.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
enum HeaderMod<'a> {
|
||||
Add { name: &'a str, value: &'a str },
|
||||
Replace { name: &'a str, value: &'a str },
|
||||
Add { name: &'a str, value: &'a str, prepend: bool },
|
||||
Replace { name: &'a str, value: &'a str, prepend: bool },
|
||||
Modify { name: &'a str, value: &'a str },
|
||||
Delete { name: &'a str },
|
||||
}
|
||||
|
||||
|
@ -290,21 +320,45 @@ impl HeaderMod<'_> {
|
|||
|
||||
// The milter library is smart enough to treat the name in a
|
||||
// case-insensitive manner, eg ‘Subject’ may replace ‘sUbject’.
|
||||
match self {
|
||||
Add { name, value } => actions.add_header(name, &ensure_lf(value)),
|
||||
Replace { name, value } => actions.replace_header(name, 1, Some(&ensure_lf(value))),
|
||||
Delete { name } => actions.replace_header(name, 1, None),
|
||||
match *self {
|
||||
Add { name, value, prepend } => add_header(actions, name, value, prepend),
|
||||
Replace { name, value, prepend } => {
|
||||
delete_header(actions, name)?;
|
||||
add_header(actions, name, value, prepend)?;
|
||||
Ok(())
|
||||
}
|
||||
Modify { name, value } => actions.replace_header(name, 1, Some(&ensure_lf(value))),
|
||||
Delete { name } => delete_header(actions, name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_header(
|
||||
actions: &impl ActionContext,
|
||||
name: &str,
|
||||
value: &str,
|
||||
prepend: bool,
|
||||
) -> milter::Result<()> {
|
||||
if prepend {
|
||||
actions.insert_header(0, name, &ensure_lf(value))
|
||||
} else {
|
||||
actions.add_header(name, &ensure_lf(value))
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_header(actions: &impl ActionContext, name: &str) -> milter::Result<()> {
|
||||
actions.replace_header(name, 1, None)
|
||||
}
|
||||
|
||||
impl Display for HeaderMod<'_> {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
use HeaderMod::*;
|
||||
|
||||
match self {
|
||||
Add { name, .. } => write!(f, "add header \"{}\"", name),
|
||||
Replace { name, .. } => write!(f, "replace header \"{}\"", name),
|
||||
Replace { name, .. } | Modify { name, .. } => {
|
||||
write!(f, "replace header \"{}\"", name)
|
||||
}
|
||||
Delete { name } => write!(f, "delete header \"{}\"", name),
|
||||
}
|
||||
}
|
||||
|
@ -370,6 +424,8 @@ mod tests {
|
|||
assert_eq!(&ensure_crlf("a\n\nb"), "a\r\n\r\nb");
|
||||
assert_eq!(&ensure_crlf("a\r\n\nb"), "a\r\n\r\nb");
|
||||
assert_eq!(&ensure_crlf("a\n\r\nb"), "a\r\n\r\nb");
|
||||
assert_eq!(&ensure_crlf("a\r\n\r\nb"), "a\r\n\r\nb");
|
||||
assert_eq!(&ensure_crlf("a\r\n\n\r\nb"), "a\r\n\r\n\r\nb");
|
||||
assert_eq!(&ensure_crlf("a\r\nb\n"), "a\r\nb\r\n");
|
||||
}
|
||||
|
||||
|
@ -415,36 +471,84 @@ mod tests {
|
|||
rewriter.process_header("X-Spam-Flag", " NO");
|
||||
rewriter.process_header("X-Spam-Flag", " YES");
|
||||
|
||||
let mut mods = rewriter.spam_assassin_mods.into_iter();
|
||||
match mods.next().unwrap() {
|
||||
HeaderMod::Add { name, value } => {
|
||||
assert_eq!(name, "X-Spam-Flag");
|
||||
assert_eq!(value, " NO");
|
||||
}
|
||||
_ => panic!(),
|
||||
}
|
||||
assert!(mods.next().is_none());
|
||||
assert_eq!(
|
||||
rewriter.spam_assassin_mods,
|
||||
[
|
||||
HeaderMod::Add {
|
||||
name: "X-Spam-Flag",
|
||||
value: " NO",
|
||||
prepend: true,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_rewriter_replaces_different_values() {
|
||||
fn header_rewriter_adds_and_replaces_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(String::from("x-spam-level"), String::from(" ***"));
|
||||
headers.insert(String::from("x-spam-report"), String::from(" original"));
|
||||
headers.insert(String::from("subject"), String::from(" original"));
|
||||
headers.insert(String::from("x-spam-prev-subject"), String::from(" very original"));
|
||||
headers.insert(String::from("to"), String::from(" recipient@gluet.ch"));
|
||||
let config = Default::default();
|
||||
|
||||
let mut rewriter = HeaderRewriter::new(headers, &config);
|
||||
rewriter.process_header("X-Spam-Level", " ***");
|
||||
rewriter.process_header("X-Spam-Report", " new");
|
||||
rewriter.process_header("X-Spam-Level", " *****");
|
||||
rewriter.process_header("X-Spam-Report", " report");
|
||||
rewriter.process_header("Subject", " [SPAM] original");
|
||||
rewriter.process_header("X-Spam-Prev-Subject", " very original");
|
||||
rewriter.process_header("From", " sender@gluet.ch");
|
||||
rewriter.process_header("To", " recipient@gluet.ch");
|
||||
|
||||
let mut mods = rewriter.spam_assassin_mods.into_iter();
|
||||
match mods.next().unwrap() {
|
||||
HeaderMod::Replace { name, value } => {
|
||||
assert_eq!(name, "X-Spam-Report");
|
||||
assert_eq!(value, " new");
|
||||
}
|
||||
_ => panic!(),
|
||||
}
|
||||
assert!(mods.next().is_none());
|
||||
assert_eq!(
|
||||
rewriter.spam_assassin_mods,
|
||||
[
|
||||
HeaderMod::Replace {
|
||||
name: "X-Spam-Level",
|
||||
value: " *****",
|
||||
prepend: true,
|
||||
},
|
||||
HeaderMod::Add {
|
||||
name: "X-Spam-Report",
|
||||
value: " report",
|
||||
prepend: true,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rewriter.rewrite_mods,
|
||||
[
|
||||
HeaderMod::Modify {
|
||||
name: "Subject",
|
||||
value: " [SPAM] original",
|
||||
},
|
||||
HeaderMod::Add {
|
||||
name: "From",
|
||||
value: " sender@gluet.ch",
|
||||
prepend: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_rewriter_adds_appended_headers() {
|
||||
let headers = HeaderMap::new();
|
||||
let config = Default::default();
|
||||
|
||||
let mut rewriter = HeaderRewriter::new(headers, &config);
|
||||
rewriter.process_header("Received", " from localhost by ...");
|
||||
rewriter.process_header("X-Spam-Flag", " YES");
|
||||
|
||||
assert_eq!(
|
||||
rewriter.spam_assassin_mods,
|
||||
[
|
||||
HeaderMod::Add {
|
||||
name: "X-Spam-Flag",
|
||||
value: " YES",
|
||||
prepend: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
//! The SpamAssassin Milter application library.
|
||||
|
||||
#![doc(html_root_url = "https://docs.rs/spamassassin-milter/0.1.6")]
|
||||
|
||||
macro_rules! verbose {
|
||||
($config:ident, $($arg:tt)*) => {
|
||||
if $config.verbose() {
|
||||
|
@ -30,7 +28,7 @@ use milter::Milter;
|
|||
pub const MILTER_NAME: &str = "SpamAssassin Milter";
|
||||
|
||||
/// The current version string of SpamAssassin Milter.
|
||||
pub const VERSION: &str = "0.1.6";
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Starts SpamAssassin Milter listening on the given socket using the supplied
|
||||
/// configuration.
|
||||
|
|
|
@ -83,7 +83,7 @@ where
|
|||
// Crude handling of the `spamc` client protocol: strip off everything
|
||||
// before and including the first "\r\n\r\n".
|
||||
let i = msg.find("\r\n\r\n").expect("spamc protocol header missing");
|
||||
msg.replace_range(..i + 4, "");
|
||||
msg.drain(..i + 4);
|
||||
|
||||
match f(msg) {
|
||||
// Again very basic handling of the `spamd` server protocol: add a
|
||||
|
|
|
@ -67,8 +67,9 @@ local err = mt.eom(conn)
|
|||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_ACCEPT)
|
||||
|
||||
assert(mt.eom_check(conn, MT_HDRCHANGE, "X-Spam-Checker-Version", " MyChecker 1.0.0"))
|
||||
assert(mt.eom_check(conn, MT_HDRADD, "X-Spam-Custom", " Custom-Value"))
|
||||
assert(mt.eom_check(conn, MT_HDRINSERT, "X-Spam-Custom", " Custom-Value"))
|
||||
assert(mt.eom_check(conn, MT_HDRDELETE, "X-Spam-Checker-Version"))
|
||||
assert(mt.eom_check(conn, MT_HDRINSERT, "X-Spam-Checker-Version", " MyChecker 1.0.0"))
|
||||
assert(mt.eom_check(conn, MT_HDRDELETE, "X-Spam-Report"))
|
||||
|
||||
local err = mt.disconnect(conn)
|
||||
|
|
|
@ -10,20 +10,18 @@ fn ham_message() {
|
|||
let config = builder.build();
|
||||
|
||||
let server = spawn_mock_spamd_server(SPAMD_PORT, |ham| {
|
||||
Ok(ham
|
||||
.replacen(
|
||||
"X-Spam-Checker-Version: BogusChecker 1.0.0\r\n",
|
||||
"X-Spam-Checker-Version: MyChecker 1.0.0\r\n",
|
||||
1,
|
||||
)
|
||||
.replacen("X-Spam-Report: Bogus report\r\n", "", 1)
|
||||
.replacen(
|
||||
"\r\n\r\n",
|
||||
"\r\n\
|
||||
X-Spam-Custom: Custom-Value\r\n\
|
||||
\r\n",
|
||||
1,
|
||||
))
|
||||
let mut ham = ham
|
||||
.replacen("X-Spam-Checker-Version: BogusChecker 1.0.0\r\n", "", 1)
|
||||
.replacen("X-Spam-Report: Bogus report\r\n", "", 1);
|
||||
|
||||
// Prepend replaced and newly added SpamAssassin headers.
|
||||
ham.insert_str(
|
||||
0,
|
||||
"X-Spam-Checker-Version: MyChecker 1.0.0\r\n\
|
||||
X-Spam-Custom: Custom-Value\r\n",
|
||||
);
|
||||
|
||||
Ok(ham)
|
||||
});
|
||||
let miltertest = spawn_miltertest_runner(file!());
|
||||
|
||||
|
|
|
@ -67,11 +67,12 @@ local err = mt.eom(conn)
|
|||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_ACCEPT)
|
||||
|
||||
assert(mt.eom_check(conn, MT_HDRCHANGE, "Subject", " [SPAM] Test message"))
|
||||
assert(mt.eom_check(conn, MT_HDRCHANGE, "X-Spam-Checker-Version", " MyChecker 1.0.0"))
|
||||
assert(mt.eom_check(conn, MT_HDRDELETE, "X-Spam-Checker-Version"))
|
||||
assert(mt.eom_check(conn, MT_HDRADD, "X-Spam-Checker-Version", " MyChecker 1.0.0"))
|
||||
assert(mt.eom_check(conn, MT_HDRADD, "X-Spam-Flag", " YES"))
|
||||
assert(mt.eom_check(conn, MT_HDRADD, "X-Spam-Custom", " Custom-Value"))
|
||||
assert(mt.eom_check(conn, MT_HDRDELETE, "X-Spam-Report"))
|
||||
assert(mt.eom_check(conn, MT_HDRCHANGE, "Subject", " [SPAM] Test message"))
|
||||
assert(mt.eom_check(conn, MT_HDRADD, "Content-Type", " multipart/mixed; ..."))
|
||||
assert(mt.eom_check(conn, MT_BODYCHANGE, "Spam detection software has identified ..."))
|
||||
|
||||
|
|
|
@ -11,21 +11,22 @@ fn spam_message() {
|
|||
|
||||
let server = spawn_mock_spamd_server(SPAMD_PORT, |spam| {
|
||||
let mut spam = spam
|
||||
.replacen("Subject: Test message\r\n", "Subject: [SPAM] Test message\r\n", 1)
|
||||
.replacen(
|
||||
"X-Spam-Checker-Version: BogusChecker 1.0.0\r\n",
|
||||
"X-Spam-Checker-Version: MyChecker 1.0.0\r\n",
|
||||
1,
|
||||
)
|
||||
.replacen("X-Spam-Checker-Version: BogusChecker 1.0.0\r\n", "", 1)
|
||||
.replacen("X-Spam-Report: Bogus report\r\n", "", 1)
|
||||
.replacen(
|
||||
"\r\n\r\n",
|
||||
"\r\n\
|
||||
X-Spam-Checker-Version: MyChecker 1.0.0\r\n\
|
||||
X-Spam-Flag: YES\r\n\
|
||||
X-Spam-Custom: Custom-Value\r\n\
|
||||
Content-Type: multipart/mixed; ...\r\n\
|
||||
\r\n",
|
||||
1,
|
||||
)
|
||||
.replacen(
|
||||
"Subject: Test message\r\n",
|
||||
"Subject: [SPAM] Test message\r\n",
|
||||
1,
|
||||
);
|
||||
|
||||
// Replace the message body with the SpamAssassin ‘report’.
|
||||
|
|
Loading…
Reference in a new issue