Merge branch 'master' into synth-relay

This commit is contained in:
David Bürgin 2021-08-26 16:58:16 +02:00
commit c9d7dbbc25
15 changed files with 325 additions and 207 deletions

View file

@ -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 MTAs `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
View file

@ -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",

View file

@ -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"]

View file

@ -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 milters 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 installations `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 files 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.
SpamAssassins `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

View file

@ -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 ,

View file

@ -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(),
)?;

View file

@ -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 dont see the
// MTAs 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())));
}
}

View file

@ -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"));
}
}

View file

@ -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,
},
]
);
}
}

View file

@ -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.

View file

@ -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

View file

@ -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)

View file

@ -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!());

View 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 ..."))

View file

@ -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.