Add tests, write man page
This commit is contained in:
parent
9d26e0cb90
commit
e6817c03e7
17 changed files with 834 additions and 151 deletions
16
Cargo.lock
generated
16
Cargo.lock
generated
|
@ -69,9 +69,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f4b06b21db0228860c8dfd17d2106c49c7c6bd07477a4036985347d84def04"
|
||||
checksum = "a859057dc563d1388c1e816f98a1892629075fc046ed06e845b883bb8b2916fb"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
|
@ -81,7 +81,9 @@ checksum = "d515b1f41455adea1313a4a2ac8a8a477634fbae63cc6100e3aebb207ce61558"
|
|||
|
||||
[[package]]
|
||||
name = "milter"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf37b1c8105474d1c2b1eef110ded017f097543f09dddf97323b3a320b540e6b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
|
@ -92,7 +94,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "milter-callback"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38bd474a4ccfa8782f6a3f8696e3c4f409bc360544b8ba30fbe4104c2844196d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
@ -101,9 +105,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "milter-sys"
|
||||
version = "0.1.4"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1b559636bc813ff22619c3a9910ab8a7abfcfede7defe1d2f70b0b5cfba1f76"
|
||||
checksum = "baabd032f5ed0fdbe52da16a442b0339951c9e538d93ad8d1662243c90da2c12"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
|
|
12
Cargo.toml
12
Cargo.toml
|
@ -1,11 +1,11 @@
|
|||
[package]
|
||||
name = "spamassassin-milter"
|
||||
version = "0.0.1"
|
||||
version = "0.0.1" # remember to update html_root_url
|
||||
description = "Milter for spam filtering with SpamAssassin"
|
||||
authors = ["David Bürgin <dbuergin@gluet.ch>"]
|
||||
edition = "2018"
|
||||
keywords = ["email", "milter", "spam", "spamassassin"]
|
||||
categories = ["email"]
|
||||
keywords = ["email", "milter", "spam", "spamassassin"]
|
||||
license = "GPL-3.0-or-later"
|
||||
repository = "https://gitlab.com/glts/spamassassin-milter"
|
||||
readme = "README.md"
|
||||
|
@ -15,9 +15,15 @@ chrono = "0.4"
|
|||
clap = "2"
|
||||
ipnet = "2"
|
||||
libc = "0.2"
|
||||
milter = { version = "0.1", path = "../milter" }
|
||||
milter = "0.1"
|
||||
once_cell = "1"
|
||||
|
||||
[badges]
|
||||
gitlab = { repository = "glts/spamassassin-milter" }
|
||||
maintenance = { status = "actively-developed" }
|
||||
|
||||
# Temporary hack to make the build work on docs.rs, see
|
||||
# https://github.com/rust-lang/docs.rs/issues/191.
|
||||
[package.metadata.docs.rs]
|
||||
rustc-args = ["--cfg", "milter_sys_docs_rs"]
|
||||
rustdoc-args = ["--cfg", "milter_sys_docs_rs"]
|
||||
|
|
|
@ -1,26 +1,135 @@
|
|||
.TH SPAMASSASSIN-MILTER 8 2020-01-30
|
||||
.TH SPAMASSASSIN-MILTER 8 2020-02-07
|
||||
.SH NAME
|
||||
spamassassin-milter \- milter for spam filtering with SpamAssassin
|
||||
.SH SYNOPSIS
|
||||
.B spamassassin-milter
|
||||
[\fB\-\-trusted-networks\fR \fIBITS\fR]
|
||||
[\fB\-a\fR]
|
||||
[\fB\-B\fR]
|
||||
[\fB\-H\fR]
|
||||
[\fB\-n\fR]
|
||||
[\fB\-r\fR]
|
||||
[\fB\-s\fR \fIBYTES\fR]
|
||||
[\fB\-t\fR \fINETS\fR]
|
||||
[\fB\-v\fR]
|
||||
.IR SOCKET
|
||||
[\fB\-\-\fR \fISPAMC_ARGS\fR...]
|
||||
.SH DESCRIPTION
|
||||
.B spamassassin-milter
|
||||
is a milter that filters email through a SpamAssassin server.
|
||||
is a milter that filters email through SpamAssassin server using the spamc
|
||||
client.
|
||||
It reads the response from SpamAssassin and adds its
|
||||
.B X-Spam-
|
||||
headers to the message, and can optionally apply header and body rewriting to
|
||||
messages flagged as spam, or reject them at the SMTP level.
|
||||
A message ‘flagged as spam’ is a message with a header
|
||||
.BR "X-Spam-Flag: YES" .
|
||||
.PP
|
||||
This is currently being developed.
|
||||
Start every sentence on a new line apparently.
|
||||
To do.
|
||||
The mandatory
|
||||
.I SOCKET
|
||||
argument specifies the listening socket to open.
|
||||
.I SOCKET
|
||||
can be either a TCP socket in the form
|
||||
.BR inet: "\fIPORT\fR" : "\fIHOST\fR"
|
||||
or
|
||||
.BR inet6: "\fIPORT\fR" : "\fIHOST\fR"
|
||||
(for example,
|
||||
.BR inet:3000:localhost ),
|
||||
or a UNIX domain socket in the form
|
||||
.BR unix: "\fIPATH\fR"
|
||||
(for example,
|
||||
.BR unix:/run/spamassassin-milter.sock ).
|
||||
After the options and argument, additional arguments
|
||||
.IR SPAMC_ARGS ...
|
||||
listed after
|
||||
.B \-\-
|
||||
are gathered as arguments to pass to the
|
||||
.B spamc
|
||||
invocation.
|
||||
.B spamassassin-milter
|
||||
has reasonable defaults and if run with no options, will apply modifications
|
||||
received from SpamAssassin server.
|
||||
.\" 3) description of larger context, spamc spamd mta (eg spamc config file!)
|
||||
.PP
|
||||
.B spamassassin-milter
|
||||
is a light-weight integration component, enabling use of SpamAssassin with a
|
||||
milter-capable MTA.
|
||||
As such, users must first be familiar with the setup and configuration options
|
||||
of the components participating, namely, the SpamAssassin programs
|
||||
.B spamd
|
||||
(SpamAssassin server) and
|
||||
.BR spamc ,
|
||||
and the MTA (Postfix).
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.BR \-a ", " \-\-auth-untrusted
|
||||
Treat authenticated senders as untrusted.
|
||||
If this option is not used, authenticated senders are trusted and their messages
|
||||
are not processed with SpamAssassin.
|
||||
.TP
|
||||
.BR \-n ", " \-\-dry-run
|
||||
Process messages as usual, but do not take action or apply any modifications.
|
||||
Combined with
|
||||
.BR \-\-verbose ,
|
||||
this gives accurate insight into what would happen if run without
|
||||
.BR \-\-dry-run .
|
||||
.TP
|
||||
.BR \-h ", " \-\-help
|
||||
Print usage information.
|
||||
.TP
|
||||
.BR \-s ", " \-\-max-message-size " \fIBYTES\fR"
|
||||
Maximum message size in bytes to pass to
|
||||
.BR spamc .
|
||||
.I BYTES
|
||||
must be equal to or greater than the max size configured for
|
||||
.BR spamc ,
|
||||
else it is possible that the body of spam messages is truncated to the size
|
||||
configured for
|
||||
.BR spamc .
|
||||
Defaults to the
|
||||
.B spamc
|
||||
default, 512000.
|
||||
.TP
|
||||
.BR \-B ", " \-\-preserve-body
|
||||
Suppress rewriting of spam message body.
|
||||
If this option is not used, the message body of messages flagged as spam (as
|
||||
well as the values of related headers
|
||||
.B MIME-Version
|
||||
and
|
||||
.BR Content-Type ,
|
||||
if necessary)
|
||||
is replaced with the body received from SpamAssassin.
|
||||
.TP
|
||||
.BR \-H ", " \-\-preserve-headers
|
||||
Suppress rewriting of headers
|
||||
.B Subject
|
||||
.B From
|
||||
.B To
|
||||
of spam messages.
|
||||
If this option is not used, these headers of messages flagged as spam will be
|
||||
replaced with the values received from SpamAssassin, if necessary.
|
||||
.TP
|
||||
.BR \-r ", " \-\-reject-spam
|
||||
Reject messages flagged as spam at the SMTP level.
|
||||
Rejection results in a permanent SMTP error being returned to the client, and
|
||||
the message is not delivered.
|
||||
.TP
|
||||
.BR \-t ", " \-\-trusted-networks " \fINETS\fR"
|
||||
Trust connections coming from the IP networks or addresses
|
||||
.IR NETS .
|
||||
Connections from IP addresses contained in trusted networks are not processed
|
||||
with SpamAssassin.
|
||||
.I NETS
|
||||
is a comma-separated list of IP network addresses, for example,
|
||||
.BR ::1/128,127.0.0.0/8,192.168.1.39 .
|
||||
If this option is not used, all connections from loopback addresses are trusted.
|
||||
.TP
|
||||
.BR \-v ", " \-\-verbose
|
||||
Enable verbose operation logging.
|
||||
If this option is not used, only unexpected error conditions are logged (that
|
||||
is, printed to stderr).
|
||||
.TP
|
||||
.BR \-\-max-message-size =\fISIZE\fR
|
||||
Maximum message size in bytes to pass to spamc.
|
||||
.BR \-V ", " \-\-version
|
||||
Print version information.
|
||||
.SH SEE ALSO
|
||||
.BR spamassassin (1p),
|
||||
.BR spamd (8p),
|
||||
|
|
|
@ -34,7 +34,6 @@ fn handle_negotiate(
|
|||
let mut req_actions = Actions::empty();
|
||||
if !config::get().dry_run() {
|
||||
req_actions |= Actions::ADD_HEADER | Actions::REPLACE_HEADER;
|
||||
|
||||
if !config::get().preserve_body() {
|
||||
req_actions |= Actions::REPLACE_BODY;
|
||||
}
|
||||
|
@ -93,7 +92,7 @@ fn handle_helo(mut ctx: Context<Connection>, helo_host: &str) -> milter::Result<
|
|||
fn handle_mail(mut ctx: Context<Connection>, smtp_args: Vec<&str>) -> milter::Result<Status> {
|
||||
let conn = ctx.data.borrow_mut().unwrap();
|
||||
|
||||
if !config::get().filter_authenticated() {
|
||||
if !config::get().auth_untrusted() {
|
||||
if let Some(login) = ctx.api.macro_value("{auth_authen}")? {
|
||||
verbose!("accepted message from sender authenticated as \"{}\"", login);
|
||||
return Ok(Status::Accept);
|
||||
|
@ -122,9 +121,15 @@ fn handle_data(mut ctx: Context<Connection>) -> milter::Result<Status> {
|
|||
let conn = ctx.data.borrow_mut().unwrap();
|
||||
let client = conn.client.as_mut().unwrap();
|
||||
|
||||
let id = queue_id(&ctx.api)?;
|
||||
|
||||
if let Err(e) = client.connect() {
|
||||
eprintln!("{}: cannot connect to spamc: {}", id, e);
|
||||
return Ok(Status::Tempfail);
|
||||
}
|
||||
|
||||
let client_ip = conn.client_ip.to_string();
|
||||
|
||||
client.connect()?;
|
||||
client.send_envelope_sender()?;
|
||||
client.send_envelope_recipients()?;
|
||||
client.send_forged_received_header(
|
||||
|
@ -135,7 +140,7 @@ fn handle_data(mut ctx: Context<Connection>) -> milter::Result<Status> {
|
|||
.and_then(|v| v.split_ascii_whitespace().next())
|
||||
.unwrap_or("Postfix"),
|
||||
ctx.api.macro_value("{tls_version}")?.is_some(),
|
||||
queue_id(&ctx.api)?,
|
||||
id,
|
||||
&Local::now().to_rfc2822(),
|
||||
)?;
|
||||
|
||||
|
@ -170,13 +175,15 @@ fn handle_body(mut ctx: Context<Connection>, bytes: &[u8]) -> milter::Result<Sta
|
|||
client.send_body_chunk(bytes)?;
|
||||
|
||||
let max = config::get().max_message_size();
|
||||
Ok(if client.bytes_written() >= max {
|
||||
let status = if client.bytes_written() >= max {
|
||||
let id = queue_id(&ctx.api)?;
|
||||
verbose!("{}: skipping rest of message larger than {} bytes", id, max);
|
||||
Status::Skip
|
||||
} else {
|
||||
Status::Continue
|
||||
})
|
||||
};
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[on_eom(eom_callback)]
|
||||
|
@ -185,12 +192,14 @@ fn handle_eom(mut ctx: Context<Connection>) -> milter::Result<Status> {
|
|||
let client = conn.client.take().unwrap();
|
||||
|
||||
let id = queue_id(&ctx.api)?;
|
||||
client.process(id, &ctx.api)
|
||||
let config = config::get();
|
||||
|
||||
client.process(id, &ctx.api, config)
|
||||
}
|
||||
|
||||
#[on_abort(abort_callback)]
|
||||
fn handle_abort(mut ctx: Context<Connection>) -> milter::Result<Status> {
|
||||
ctx.data.borrow_mut().unwrap().client.take();
|
||||
ctx.data.borrow_mut().unwrap().client = None;
|
||||
|
||||
Ok(Status::Continue)
|
||||
}
|
||||
|
|
236
src/client.rs
236
src/client.rs
|
@ -1,9 +1,9 @@
|
|||
use crate::{
|
||||
config,
|
||||
config::Config,
|
||||
email::{self, Email, HeaderMap, HeaderRewriter},
|
||||
error::{Error, Result},
|
||||
};
|
||||
use milter::{ActionMethods, Status};
|
||||
use milter::{ActionContext, SetErrorReply, Status};
|
||||
use std::{
|
||||
any::Any,
|
||||
io::Write,
|
||||
|
@ -184,7 +184,12 @@ impl Client {
|
|||
Ok(self.bytes += writer.write(bytes)?)
|
||||
}
|
||||
|
||||
pub fn process(mut self, id: &str, actions: &impl ActionMethods) -> milter::Result<Status> {
|
||||
pub fn process(
|
||||
mut self,
|
||||
id: &str,
|
||||
actions: &(impl ActionContext + SetErrorReply),
|
||||
config: &Config,
|
||||
) -> milter::Result<Status> {
|
||||
let output = match self.process.finish() {
|
||||
Ok(output) => output,
|
||||
Err(e) => {
|
||||
|
@ -201,60 +206,70 @@ impl Client {
|
|||
}
|
||||
};
|
||||
|
||||
let mut rewriter = HeaderRewriter::new(self.headers);
|
||||
|
||||
let mut rewriter = HeaderRewriter::new(self.headers, config);
|
||||
for header in email.header {
|
||||
rewriter.process_header(header.name, header.value);
|
||||
}
|
||||
|
||||
let spam = rewriter.is_flagged_spam();
|
||||
let dry_run = config::get().dry_run();
|
||||
|
||||
if spam && config::get().reject_spam() {
|
||||
let status = if dry_run {
|
||||
verbose!("{}: rejected message flagged as spam [dry-run, not done]", id);
|
||||
Status::Accept
|
||||
} else {
|
||||
verbose!("{}: rejected message flagged as spam", id);
|
||||
Status::Reject
|
||||
};
|
||||
|
||||
return Ok(status);
|
||||
if spam && config.reject_spam() {
|
||||
return reject_spam(id, actions, config);
|
||||
}
|
||||
|
||||
let actions = if dry_run { None } else { Some(actions) };
|
||||
|
||||
rewriter.rewrite_spam_assassin_headers(id, actions)?;
|
||||
|
||||
if spam {
|
||||
if !config::get().preserve_headers() {
|
||||
if !config.preserve_headers() {
|
||||
rewriter.rewrite_rewrite_headers(id, actions)?;
|
||||
}
|
||||
if !config::get().preserve_body() {
|
||||
if !config.preserve_body() {
|
||||
rewriter.rewrite_report_headers(id, actions)?;
|
||||
email::replace_body(id, email.body, actions)?;
|
||||
email::replace_body(id, email.body, actions, config)?;
|
||||
}
|
||||
}
|
||||
|
||||
verbose!("{}: finished processing", id);
|
||||
verbose!(config, "{}: finished processing", id);
|
||||
Ok(Status::Accept)
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_spam(
|
||||
id: &str,
|
||||
actions: &impl SetErrorReply,
|
||||
config: &Config,
|
||||
) -> milter::Result<Status> {
|
||||
Ok(if config.dry_run() {
|
||||
verbose!(config, "{}: rejected message flagged as spam [dry-run, not done]", id);
|
||||
Status::Accept
|
||||
} else {
|
||||
// TODO Message text needed? For example "Rejected spam."?
|
||||
actions.set_error_reply("550", Some("5.7.1"), vec![])?;
|
||||
|
||||
verbose!(config, "{}: rejected message flagged as spam", id);
|
||||
Status::Reject
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use std::{cell::RefCell, collections::HashSet};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
struct MockSpamc {
|
||||
buf: Vec<u8>,
|
||||
output: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MockSpamc {
|
||||
pub fn new() -> Self {
|
||||
fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn with_output(output: Vec<u8>) -> Self {
|
||||
Self { output: Some(output), ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Process for MockSpamc {
|
||||
|
@ -267,7 +282,11 @@ mod tests {
|
|||
}
|
||||
|
||||
fn finish(&mut self) -> Result<Vec<u8>> {
|
||||
Ok(self.buf.clone())
|
||||
Ok(if let Some(output) = &self.output {
|
||||
output.clone()
|
||||
} else {
|
||||
self.buf.clone()
|
||||
})
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
|
@ -279,22 +298,167 @@ mod tests {
|
|||
process.as_any().downcast_ref().unwrap()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
enum Action {
|
||||
AddHeader(String, String),
|
||||
ReplaceHeader(String, usize, Option<String>),
|
||||
AppendBodyChunk(Vec<u8>),
|
||||
SetErrorReply(String, Option<String>, Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct MockActionContext {
|
||||
called: RefCell<Vec<Action>>,
|
||||
}
|
||||
|
||||
impl MockActionContext {
|
||||
fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionContext for MockActionContext {
|
||||
fn add_header(&self, name: &str, value: &str) -> milter::Result<()> {
|
||||
Ok(self.called.borrow_mut().push(
|
||||
Action::AddHeader(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()))
|
||||
))
|
||||
}
|
||||
|
||||
fn append_body_chunk(&self, bytes: &[u8]) -> milter::Result<()> {
|
||||
Ok(self.called.borrow_mut().push(
|
||||
Action::AppendBodyChunk(bytes.to_owned())
|
||||
))
|
||||
}
|
||||
|
||||
fn replace_sender(&self, _: &str, _: Option<&str>) -> milter::Result<()> {
|
||||
unimplemented!();
|
||||
}
|
||||
|
||||
fn add_recipient(&self, _: &str, _: Option<&str>) -> milter::Result<()> {
|
||||
unimplemented!();
|
||||
}
|
||||
|
||||
fn remove_recipient(&self, _: &str) -> milter::Result<()> {
|
||||
unimplemented!();
|
||||
}
|
||||
|
||||
fn insert_header(&self, _: usize, _: &str, _: &str) -> milter::Result<()> {
|
||||
unimplemented!();
|
||||
}
|
||||
|
||||
fn quarantine(&self, _: &str) -> milter::Result<()> {
|
||||
unimplemented!();
|
||||
}
|
||||
|
||||
fn signal_progress(&self) -> milter::Result<()> {
|
||||
unimplemented!();
|
||||
}
|
||||
}
|
||||
|
||||
impl SetErrorReply for MockActionContext {
|
||||
fn set_error_reply(
|
||||
&self,
|
||||
code: &str,
|
||||
ext_code: Option<&str>,
|
||||
msg_lines: Vec<&str>,
|
||||
) -> milter::Result<()> {
|
||||
Ok(self.called.borrow_mut().push(Action::SetErrorReply(
|
||||
code.to_owned(),
|
||||
ext_code.map(|c| c.to_owned()),
|
||||
msg_lines.into_iter().map(|l| l.to_owned()).collect(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_flow() {
|
||||
let config = Config::builder().build();
|
||||
config::init(config);
|
||||
|
||||
fn client_send_writes_bytes() {
|
||||
let spamc = MockSpamc::new();
|
||||
let sender = String::from("sender");
|
||||
|
||||
let mut client = Client::new(spamc, sender);
|
||||
client.send_header("name", "value").unwrap();
|
||||
let mut client = Client::new(spamc, String::from("sender"));
|
||||
client.send_header("name1", "value1").unwrap();
|
||||
client.send_header("name2", "value2\n\tcontinued").unwrap();
|
||||
client.send_eoh().unwrap();
|
||||
client.send_body_chunk(b"body").unwrap();
|
||||
|
||||
assert_eq!(client.bytes_written(), 19);
|
||||
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 &[_])
|
||||
);
|
||||
}
|
||||
|
||||
let spamc = as_mock_spamc(client.process.as_ref());
|
||||
assert_eq!(&spamc.buf, b"name: value\r\n\r\nbody");
|
||||
#[test]
|
||||
fn client_send_no_spam_assassin_headers() {
|
||||
let spamc = MockSpamc::new();
|
||||
|
||||
let mut client = Client::new(spamc, String::from("sender"));
|
||||
client.send_header("x-spam-flag", "YES").unwrap();
|
||||
client.send_header("x-spam-bogus", "x").unwrap();
|
||||
|
||||
assert_eq!(client.bytes_written(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_process_invalid_response() {
|
||||
let spamc = MockSpamc::with_output(b"invalid message response".to_vec());
|
||||
let actions = MockActionContext::new();
|
||||
let config = Config::builder().build();
|
||||
|
||||
let client = Client::new(spamc, String::from("sender"));
|
||||
let status = client.process("id", &actions, &config).unwrap();
|
||||
|
||||
assert_eq!(status, Status::Tempfail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_process_reject_spam() {
|
||||
let spamc = MockSpamc::with_output(b"X-Spam-Flag: YES\r\n\r\n".to_vec());
|
||||
let actions = MockActionContext::new();
|
||||
let mut builder = Config::builder();
|
||||
builder.set_reject_spam(true);
|
||||
let config = builder.build();
|
||||
|
||||
let client = Client::new(spamc, String::from("sender"));
|
||||
let status = client.process("id", &actions, &config).unwrap();
|
||||
|
||||
assert_eq!(status, Status::Reject);
|
||||
assert_eq!(
|
||||
actions.called.borrow().first(),
|
||||
Some(&Action::SetErrorReply("550".into(), Some("5.7.1".into()), vec![]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_process_rewrite_spam() {
|
||||
let spamc = MockSpamc::with_output(b"X-Spam-Flag: YES\r\nX-Spam-Level: *****\r\n\r\nReport".to_vec());
|
||||
let actions = MockActionContext::new();
|
||||
let config = Config::builder().build();
|
||||
|
||||
let mut client = Client::new(spamc, String::from("sender"));
|
||||
client.send_header("x-spam-level", "*").unwrap();
|
||||
client.send_header("x-spam-report", "...").unwrap();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,11 +2,11 @@ use std::mem;
|
|||
|
||||
/// An array map with ASCII-case-insensitive `&str` keys.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct StrArrayMap<'k, V> {
|
||||
pub struct StrVecMap<'k, V> {
|
||||
entries: Vec<(&'k str, V)>,
|
||||
}
|
||||
|
||||
impl<'k, V> StrArrayMap<'k, V> {
|
||||
impl<'k, V> StrVecMap<'k, V> {
|
||||
pub fn new() -> Self {
|
||||
Self { entries: Default::default() }
|
||||
}
|
||||
|
@ -53,11 +53,11 @@ impl<'k, V> StrArrayMap<'k, V> {
|
|||
|
||||
/// An array set containing ASCII-case-insensitive `&str` elements.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct StrArraySet<'e> {
|
||||
map: StrArrayMap<'e, ()>,
|
||||
pub struct StrVecSet<'e> {
|
||||
map: StrVecMap<'e, ()>,
|
||||
}
|
||||
|
||||
impl<'e> StrArraySet<'e> {
|
||||
impl<'e> StrVecSet<'e> {
|
||||
pub fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
@ -85,7 +85,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn map_contains_key() {
|
||||
let mut map = StrArrayMap::new();
|
||||
let mut map = StrVecMap::new();
|
||||
map.insert("KEY1", 1);
|
||||
|
||||
assert_eq!(map.contains_key("key1"), true);
|
||||
|
@ -94,7 +94,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn map_get_key_value() {
|
||||
let mut map = StrArrayMap::new();
|
||||
let mut map = StrVecMap::new();
|
||||
map.insert("KEY1", 1);
|
||||
|
||||
assert_eq!(map.get_key_value("key1"), Some(("KEY1", &1)));
|
||||
|
@ -103,7 +103,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn map_insert() {
|
||||
let mut map = StrArrayMap::new();
|
||||
let mut map = StrVecMap::new();
|
||||
|
||||
assert_eq!(map.insert("KEY1", 1), None);
|
||||
assert_eq!(map.insert("KEY2", 2), None);
|
||||
|
@ -117,7 +117,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn map_insert_if_absent() {
|
||||
let mut map = StrArrayMap::new();
|
||||
let mut map = StrVecMap::new();
|
||||
|
||||
assert_eq!(map.insert_if_absent("KEY1", 1), None);
|
||||
assert_eq!(map.insert_if_absent("KEY2", 2), None);
|
||||
|
@ -131,7 +131,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn set_get() {
|
||||
let mut set = StrArraySet::new();
|
||||
let mut set = StrVecSet::new();
|
||||
set.insert("KEY1");
|
||||
|
||||
assert_eq!(set.get("key1"), Some("KEY1"));
|
||||
|
@ -140,7 +140,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn set_insert() {
|
||||
let mut set = StrArraySet::new();
|
||||
let mut set = StrVecSet::new();
|
||||
|
||||
assert_eq!(set.insert("KEY1"), true);
|
||||
assert_eq!(set.insert("KEY2"), true);
|
||||
|
|
|
@ -7,7 +7,7 @@ use std::{collections::HashSet, net::IpAddr};
|
|||
/// [`Config`]: struct.Config.html
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ConfigBuilder {
|
||||
filter_authenticated: bool,
|
||||
auth_untrusted: bool,
|
||||
reject_spam: bool,
|
||||
preserve_headers: bool,
|
||||
preserve_body: bool,
|
||||
|
@ -31,8 +31,8 @@ impl ConfigBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn set_filter_authenticated(&mut self, value: bool) -> &mut Self {
|
||||
self.filter_authenticated = value;
|
||||
pub fn set_auth_untrusted(&mut self, value: bool) -> &mut Self {
|
||||
self.auth_untrusted = value;
|
||||
self
|
||||
}
|
||||
|
||||
|
@ -56,8 +56,8 @@ impl ConfigBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn set_spamc_args(&mut self, spamc_args: Vec<String>) -> &mut Self {
|
||||
self.spamc_args = spamc_args;
|
||||
pub fn set_spamc_args(&mut self, value: Vec<String>) -> &mut Self {
|
||||
self.spamc_args = value;
|
||||
self
|
||||
}
|
||||
|
||||
|
@ -73,7 +73,7 @@ impl ConfigBuilder {
|
|||
|
||||
pub fn build(self) -> Config {
|
||||
Config {
|
||||
filter_authenticated: self.filter_authenticated,
|
||||
auth_untrusted: self.auth_untrusted,
|
||||
reject_spam: self.reject_spam,
|
||||
preserve_headers: self.preserve_headers,
|
||||
preserve_body: self.preserve_body,
|
||||
|
@ -90,7 +90,7 @@ impl ConfigBuilder {
|
|||
/// A configuration object for SpamAssassin Milter.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Config {
|
||||
filter_authenticated: bool,
|
||||
auth_untrusted: bool,
|
||||
reject_spam: bool,
|
||||
preserve_headers: bool,
|
||||
preserve_body: bool,
|
||||
|
@ -113,8 +113,8 @@ impl Config {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn filter_authenticated(&self) -> bool {
|
||||
self.filter_authenticated
|
||||
pub fn auth_untrusted(&self) -> bool {
|
||||
self.auth_untrusted
|
||||
}
|
||||
|
||||
pub fn reject_spam(&self) -> bool {
|
||||
|
|
171
src/email.rs
171
src/email.rs
|
@ -1,8 +1,9 @@
|
|||
use crate::{
|
||||
collections::{StrArrayMap, StrArraySet},
|
||||
collections::{StrVecMap, StrVecSet},
|
||||
config::Config,
|
||||
error::{Error, Result},
|
||||
};
|
||||
use milter::ActionMethods;
|
||||
use milter::ActionContext;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::{
|
||||
cmp,
|
||||
|
@ -65,7 +66,7 @@ fn header_lines(header: &[u8]) -> Vec<&[u8]> {
|
|||
}
|
||||
|
||||
if start != i {
|
||||
lines.push(&header[start..]);
|
||||
lines.push(&header[start..i]);
|
||||
}
|
||||
|
||||
lines
|
||||
|
@ -86,7 +87,7 @@ fn parse_header_line(bytes: &[u8]) -> Result<Header<'_>> {
|
|||
}
|
||||
|
||||
pub fn ensure_crlf(s: &str) -> String {
|
||||
// Ensure existing occurrences of "\r\n" remain unchanged.
|
||||
// For symmetry, ensure existing occurrences of b"\r\n" remain unchanged.
|
||||
s.split('\n')
|
||||
.map(|line| match line.as_bytes().last() {
|
||||
Some(&last) if last == b'\r' => &line[..(line.len() - 1)],
|
||||
|
@ -107,9 +108,10 @@ pub fn is_spam_assassin_header(name: &str) -> bool {
|
|||
name[..cmp::min(prefix.len(), name.len())].eq_ignore_ascii_case(prefix)
|
||||
}
|
||||
|
||||
pub type HeaderMap<'k> = StrArrayMap<'k, String>; // values use CRLF line breaks
|
||||
pub type HeaderSet<'e> = StrArraySet<'e>;
|
||||
pub type HeaderMap<'k> = StrVecMap<'k, String>; // values use CRLF line breaks
|
||||
pub type HeaderSet<'e> = StrVecSet<'e>;
|
||||
|
||||
// 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");
|
||||
|
@ -138,24 +140,30 @@ pub static REPORT_HEADERS: Lazy<HeaderSet<'static>> = Lazy::new(|| {
|
|||
/// A header rewriter going between incoming headers, and headers returned from
|
||||
/// the SpamAssassin service. Operates only on the first occurrence of headers
|
||||
/// with the same name.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HeaderRewriter<'a> {
|
||||
original: HeaderMap<'a>,
|
||||
processed: HeaderSet<'a>,
|
||||
spam_assassin_mods: Vec<HeaderMod<'a>>,
|
||||
rewrite_mods: Vec<HeaderMod<'a>>,
|
||||
report_mods: Vec<HeaderMod<'a>>,
|
||||
config: &'a Config,
|
||||
}
|
||||
|
||||
impl<'a> HeaderRewriter<'a> {
|
||||
pub fn new(original: HeaderMap<'a>) -> Self {
|
||||
pub fn new(original: HeaderMap<'a>, config: &'a Config) -> Self {
|
||||
Self {
|
||||
original,
|
||||
..Default::default()
|
||||
processed: Default::default(),
|
||||
spam_assassin_mods: Default::default(),
|
||||
rewrite_mods: Default::default(),
|
||||
report_mods: Default::default(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_header(&mut self, name: &'a str, value: &'a str) {
|
||||
// Assumes that the value is normalised to using CRLF line breaks.
|
||||
if is_spam_assassin_header(name) {
|
||||
if let Some(m) = self.convert_to_header_mod(name, value) {
|
||||
self.spam_assassin_mods.push(m);
|
||||
|
@ -202,25 +210,25 @@ impl<'a> HeaderRewriter<'a> {
|
|||
pub fn rewrite_report_headers(
|
||||
&self,
|
||||
id: &str,
|
||||
actions: Option<&impl ActionMethods>,
|
||||
actions: &impl ActionContext,
|
||||
) -> milter::Result<()> {
|
||||
execute_mods(id, self.report_mods.iter(), actions)
|
||||
execute_mods(id, self.report_mods.iter(), actions, self.config)
|
||||
}
|
||||
|
||||
pub fn rewrite_rewrite_headers(
|
||||
&self,
|
||||
id: &str,
|
||||
actions: Option<&impl ActionMethods>,
|
||||
actions: &impl ActionContext,
|
||||
) -> milter::Result<()> {
|
||||
execute_mods(id, self.rewrite_mods.iter(), actions)
|
||||
execute_mods(id, self.rewrite_mods.iter(), actions, self.config)
|
||||
}
|
||||
|
||||
pub fn rewrite_spam_assassin_headers(
|
||||
&self,
|
||||
id: &str,
|
||||
actions: Option<&impl ActionMethods>,
|
||||
actions: &impl ActionContext,
|
||||
) -> milter::Result<()> {
|
||||
execute_mods(id, self.spam_assassin_mods.iter(), actions)?;
|
||||
execute_mods(id, self.spam_assassin_mods.iter(), actions, self.config)?;
|
||||
|
||||
// Delete incoming SpamAssassin headers not returned by SpamAssassin.
|
||||
let deletions = SPAM_ASSASSIN_HEADERS.iter()
|
||||
|
@ -228,24 +236,25 @@ impl<'a> HeaderRewriter<'a> {
|
|||
.map(|name| HeaderMod::Delete { name })
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
execute_mods(id, deletions.iter(), actions)
|
||||
execute_mods(id, deletions.iter(), actions, self.config)
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_mods<'a, I>(
|
||||
id: &str,
|
||||
mods: I,
|
||||
actions: Option<&impl ActionMethods>,
|
||||
actions: &impl ActionContext,
|
||||
config: &Config,
|
||||
) -> milter::Result<()>
|
||||
where
|
||||
I: IntoIterator<Item = &'a HeaderMod<'a>>,
|
||||
{
|
||||
Ok(for m in mods.into_iter() {
|
||||
if let Some(actions) = actions {
|
||||
verbose!("{}: rewriting header: {}", id, m);
|
||||
m.execute(actions)?;
|
||||
if config.dry_run() {
|
||||
verbose!(config, "{}: rewriting header: {} [dry-run, not done]", id, m);
|
||||
} else {
|
||||
verbose!("{}: rewriting header: {} [dry-run, not done]", id, m);
|
||||
verbose!(config, "{}: rewriting header: {}", id, m);
|
||||
m.execute(actions)?;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -253,13 +262,14 @@ where
|
|||
pub fn replace_body(
|
||||
id: &str,
|
||||
body: &[u8],
|
||||
actions: Option<&impl ActionMethods>,
|
||||
actions: &impl ActionContext,
|
||||
config: &Config,
|
||||
) -> milter::Result<()> {
|
||||
Ok(if let Some(actions) = actions {
|
||||
verbose!("{}: replacing message body", id);
|
||||
actions.append_body_chunk(body)?;
|
||||
Ok(if config.dry_run() {
|
||||
verbose!(config, "{}: replacing message body [dry-run, not done]", id);
|
||||
} else {
|
||||
verbose!("{}: replacing message body [dry-run, not done]", id);
|
||||
verbose!(config, "{}: replacing message body", id);
|
||||
actions.append_body_chunk(body)?;
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -273,9 +283,11 @@ enum HeaderMod<'a> {
|
|||
}
|
||||
|
||||
impl HeaderMod<'_> {
|
||||
fn execute(&self, actions: &impl ActionMethods) -> milter::Result<()> {
|
||||
fn execute(&self, actions: &impl ActionContext) -> milter::Result<()> {
|
||||
use HeaderMod::*;
|
||||
|
||||
// Experiment shows that the milter library is smart enough to treat the
|
||||
// name in 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))),
|
||||
|
@ -322,4 +334,109 @@ mod tests {
|
|||
assert_eq!(header_lines(b"x\r\ny"), vec![b"x" as &[_], b"y" as &[_]]);
|
||||
assert_eq!(header_lines(b"x\r\ny\r\n"), vec![b"x" as &[_], b"y" as &[_]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_header_lines_multi() {
|
||||
assert_eq!(header_lines(b"x\r\n\t"), vec![b"x\r\n\t" as &[_]]);
|
||||
assert_eq!(header_lines(b"x\r\n\ty"), vec![b"x\r\n\ty" as &[_]]);
|
||||
assert_eq!(header_lines(b"x\r\n\ty\r\n"), vec![b"x\r\n\ty" as &[_]]);
|
||||
assert_eq!(header_lines(b"x\r\n\ty\r\n\tz\r\nq"), vec![b"x\r\n\ty\r\n\tz" as &[_], b"q" as &[_]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_parse_header_line() {
|
||||
assert_eq!(parse_header_line(b"no colon"), Err(Error::ParseEmail));
|
||||
assert_eq!(parse_header_line(b":empty name"), Err(Error::ParseEmail));
|
||||
assert_eq!(parse_header_line(b"\t : whitespace name"), Err(Error::ParseEmail));
|
||||
assert_eq!(parse_header_line(b"name:value"), Ok(Header { name: "name", value: "value" }));
|
||||
assert_eq!(parse_header_line(b"name: value"), Ok(Header { name: "name", value: "value" }));
|
||||
assert_eq!(parse_header_line(b"name:\r\n\tvalue"), Ok(Header { name: "name", value: "\r\n\tvalue" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_crlf_ok() {
|
||||
assert_eq!(&ensure_crlf(""), "");
|
||||
assert_eq!(&ensure_crlf("\n"), "\r\n");
|
||||
assert_eq!(&ensure_crlf("\r\n"), "\r\n");
|
||||
assert_eq!(&ensure_crlf("a\nb"), "a\r\nb");
|
||||
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\nb\n"), "a\r\nb\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_lf_ok() {
|
||||
assert_eq!(&ensure_lf(""), "");
|
||||
assert_eq!(&ensure_lf("\n"), "\n");
|
||||
assert_eq!(&ensure_lf("\r\n"), "\n");
|
||||
assert_eq!(&ensure_lf("a\nb"), "a\nb");
|
||||
assert_eq!(&ensure_lf("a\n\nb"), "a\n\nb");
|
||||
assert_eq!(&ensure_lf("a\r\n\nb"), "a\n\nb");
|
||||
assert_eq!(&ensure_lf("a\n\r\nb"), "a\n\nb");
|
||||
assert_eq!(&ensure_lf("a\r\nb\n"), "a\nb\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spam_assassin_header_predicate() {
|
||||
assert!(is_spam_assassin_header("x-spam-status"));
|
||||
assert!(is_spam_assassin_header("x-spam-bogus"));
|
||||
assert!(is_spam_assassin_header("x-spam-"));
|
||||
assert!(!is_spam_assassin_header("x-spam"));
|
||||
assert!(!is_spam_assassin_header("bogus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_rewriter_flags_spam() {
|
||||
let config = Config::builder().build();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-spam-flag", String::from("no"));
|
||||
|
||||
let mut rewriter = HeaderRewriter::new(headers, &config);
|
||||
rewriter.process_header("X-Spam-Flag", "YES");
|
||||
|
||||
assert!(rewriter.is_flagged_spam());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_rewriter_processes_first_occurrence_only() {
|
||||
let config = Config::builder().build();
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
let mut rewriter = HeaderRewriter::new(headers, &config);
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_rewriter_replaces_different_values() {
|
||||
let config = Config::builder().build();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-spam-level", String::from("***"));
|
||||
headers.insert("x-spam-report", String::from("original"));
|
||||
|
||||
let mut rewriter = HeaderRewriter::new(headers, &config);
|
||||
rewriter.process_header("X-Spam-Level", "***");
|
||||
rewriter.process_header("X-Spam-Report", "new");
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
11
src/lib.rs
11
src/lib.rs
|
@ -1,8 +1,14 @@
|
|||
//! The SpamAssassin Milter application library.
|
||||
|
||||
#![doc(html_root_url = "https://docs.rs/spamassassin-milter/0.0.1")]
|
||||
#![macro_use]
|
||||
|
||||
macro_rules! verbose {
|
||||
($config:ident, $($arg:tt)*) => {
|
||||
if $config.verbose() {
|
||||
::std::eprintln!($($arg)*);
|
||||
}
|
||||
};
|
||||
($($arg:tt)*) => {
|
||||
if $crate::config::get().verbose() {
|
||||
::std::eprintln!($($arg)*);
|
||||
|
@ -31,6 +37,11 @@ pub const VERSION: &str = "0.0.1";
|
|||
/// configuration.
|
||||
///
|
||||
/// This is a blocking call.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If execution of the milter fails, an error variant of type `milter::Error`
|
||||
/// is returned.
|
||||
pub fn run(socket: &str, config: Config) -> milter::Result<()> {
|
||||
config::init(config);
|
||||
|
||||
|
|
92
src/main.rs
92
src/main.rs
|
@ -1,14 +1,10 @@
|
|||
use clap::{App, Arg, ArgMatches};
|
||||
use ipnet::{IpNet, Ipv4Net, Ipv6Net};
|
||||
use spamassassin_milter::Config;
|
||||
use std::{
|
||||
net::{AddrParseError, IpAddr},
|
||||
process,
|
||||
};
|
||||
use std::{net::IpAddr, process};
|
||||
|
||||
const ARG_SOCKET: &str = "SOCKET";
|
||||
const ARG_TRUSTED_NETWORKS: &str = "TRUSTED_NETWORKS";
|
||||
const ARG_FILTER_AUTHENTICATED: &str = "FILTER_AUTHENTICATED";
|
||||
const ARG_AUTH_UNTRUSTED: &str = "AUTH_UNTRUSTED";
|
||||
const ARG_DRY_RUN: &str = "DRY_RUN";
|
||||
const ARG_PRESERVE_HEADERS: &str = "PRESERVE_HEADERS";
|
||||
const ARG_PRESERVE_BODY: &str = "PRESERVE_BODY";
|
||||
|
@ -24,30 +20,36 @@ fn main() {
|
|||
.help("Listening socket of the milter")
|
||||
.required(true))
|
||||
.arg(Arg::with_name(ARG_TRUSTED_NETWORKS)
|
||||
.long("trusted-networks")
|
||||
.short("t")
|
||||
.long("trusted-networks")
|
||||
.value_name("NETS")
|
||||
.use_delimiter(true)
|
||||
.help("Trust connections from comma-separated networks"))
|
||||
.arg(Arg::with_name(ARG_FILTER_AUTHENTICATED)
|
||||
.long("filter-authenticated")
|
||||
.help("Also filter messages of authenticated senders"))
|
||||
.help("Trust connections from these networks"))
|
||||
.arg(Arg::with_name(ARG_AUTH_UNTRUSTED)
|
||||
.short("a")
|
||||
.long("auth-untrusted")
|
||||
.help("Treat authenticated senders as untrusted"))
|
||||
.arg(Arg::with_name(ARG_DRY_RUN)
|
||||
.short("n")
|
||||
.long("dry-run")
|
||||
.help("Process message but preserve message exactly as-is"))
|
||||
.help("Process messages without applying any changes"))
|
||||
.arg(Arg::with_name(ARG_PRESERVE_HEADERS)
|
||||
.short("H")
|
||||
.long("preserve-headers")
|
||||
.help("Suppress Subject, To, From header rewriting"))
|
||||
.help("Suppress rewriting of Subject/From/To headers"))
|
||||
.arg(Arg::with_name(ARG_PRESERVE_BODY)
|
||||
.short("B")
|
||||
.long("preserve-body")
|
||||
.help("Suppress rewriting of message body"))
|
||||
.arg(Arg::with_name(ARG_REJECT_SPAM)
|
||||
.short("r")
|
||||
.long("reject-spam")
|
||||
.help("Reject messages flagged as spam"))
|
||||
.arg(Arg::with_name(ARG_MAX_MESSAGE_SIZE)
|
||||
.short("s")
|
||||
.long("max-message-size")
|
||||
.value_name("SIZE")
|
||||
.help("Maximum message size in bytes to pass to spamc"))
|
||||
.value_name("BYTES")
|
||||
.help("Maximum message size to process"))
|
||||
.arg(Arg::with_name(ARG_VERBOSE)
|
||||
.short("v")
|
||||
.long("verbose")
|
||||
|
@ -59,10 +61,13 @@ fn main() {
|
|||
.get_matches();
|
||||
|
||||
let socket = matches.value_of(ARG_SOCKET).unwrap();
|
||||
let config = build_config(&matches);
|
||||
|
||||
// println!("{:?}", i);
|
||||
// println!("{:?}", matches.values_of("spamc"));
|
||||
let config = match build_config(&matches) {
|
||||
Ok(config) => config,
|
||||
Err(msg) => {
|
||||
eprintln!("error: {}", msg);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = spamassassin_milter::run(socket, config) {
|
||||
eprintln!("error: {}", e);
|
||||
|
@ -70,19 +75,37 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
fn build_config(matches: &ArgMatches<'_>) -> Config {
|
||||
fn build_config(matches: &ArgMatches<'_>) -> Result<Config, String> {
|
||||
let mut config = Config::builder();
|
||||
|
||||
if let Some(t) = matches.values_of(ARG_TRUSTED_NETWORKS) {
|
||||
config.set_has_trusted_networks(true);
|
||||
|
||||
for x in t.filter(|x| !x.trim().is_empty()) {
|
||||
config.add_trusted_network(parse_ip_net(x).unwrap());
|
||||
for n in t.filter(|n| !n.trim().is_empty()) {
|
||||
match n.parse().or_else(|_| n.parse::<IpAddr>().map(From::from)) {
|
||||
Ok(net) => {
|
||||
config.add_trusted_network(net);
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(format!("invalid trusted network address \"{}\"", n));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if matches.is_present(ARG_FILTER_AUTHENTICATED) {
|
||||
config.set_filter_authenticated(true);
|
||||
if let Some(s) = matches.value_of(ARG_MAX_MESSAGE_SIZE) {
|
||||
match s.parse() {
|
||||
Ok(bytes) => {
|
||||
config.set_max_message_size(bytes);
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(format!("invalid max message size \"{}\"", s));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matches.is_present(ARG_AUTH_UNTRUSTED) {
|
||||
config.set_auth_untrusted(true);
|
||||
}
|
||||
if matches.is_present(ARG_REJECT_SPAM) {
|
||||
config.set_reject_spam(true);
|
||||
|
@ -100,26 +123,9 @@ fn build_config(matches: &ArgMatches<'_>) -> Config {
|
|||
config.set_verbose(true);
|
||||
}
|
||||
|
||||
if let Some(s) = matches.value_of(ARG_MAX_MESSAGE_SIZE) {
|
||||
config.set_max_message_size(s.parse().unwrap());
|
||||
}
|
||||
|
||||
if let Some(s) = matches.values_of(ARG_SPAMC_ARGS) {
|
||||
config.set_spamc_args(s.map(|arg| arg.to_owned()).collect::<Vec<_>>());
|
||||
};
|
||||
|
||||
config.build()
|
||||
Ok(config.build())
|
||||
}
|
||||
|
||||
fn parse_ip_net(i: &str) -> Result<IpNet, AddrParseError> {
|
||||
i.parse::<IpNet>().or_else(|_| {
|
||||
i.parse::<IpAddr>().map(|addr| match addr {
|
||||
IpAddr::V4(addr) => IpNet::V4(Ipv4Net::new(addr, 32).unwrap()),
|
||||
IpAddr::V6(addr) => IpNet::V6(Ipv6Net::new(addr, 128).unwrap()),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// fn parse_ip_net(i: &str) -> Result<IpNet, AddrParseError> {
|
||||
// i.parse::<IpNet>().or_else(|_| i.parse::<IpAddr>().map(From::from))
|
||||
// }
|
||||
|
|
81
tests/common/mod.rs
Normal file
81
tests/common/mod.rs
Normal file
|
@ -0,0 +1,81 @@
|
|||
use std::{
|
||||
ffi::OsString,
|
||||
io::{Read, Write},
|
||||
net::{Ipv4Addr, Shutdown, SocketAddrV4, TcpListener},
|
||||
path::PathBuf,
|
||||
process::{Command, ExitStatus},
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const MILTERTEST: &str = "miltertest";
|
||||
|
||||
pub fn spawn_echo_server(port: u16) -> JoinHandle<()> {
|
||||
let socket_addr = SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), port);
|
||||
|
||||
thread::spawn(move || {
|
||||
let listener = TcpListener::bind(socket_addr).unwrap();
|
||||
|
||||
// TODO do not run for ever?
|
||||
for stream in listener.incoming() {
|
||||
let mut s = stream.unwrap();
|
||||
|
||||
let mut v = Vec::new();
|
||||
|
||||
s.read_to_end(&mut v).unwrap();
|
||||
|
||||
s.write_all(&v).unwrap();
|
||||
|
||||
s.shutdown(Shutdown::Write).unwrap();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn spawn_miltertest_runner(test_file_name: &str) -> JoinHandle<ExitStatus> {
|
||||
let _timeout_thread = thread::spawn(|| {
|
||||
thread::sleep(Duration::from_secs(15));
|
||||
|
||||
eprintln!("miltertest runner timed out");
|
||||
|
||||
milter::shutdown();
|
||||
});
|
||||
|
||||
let file_name = to_miltertest_file_name(test_file_name);
|
||||
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
|
||||
let output = Command::new(MILTERTEST)
|
||||
.arg("-s")
|
||||
.arg(&file_name)
|
||||
.output()
|
||||
.expect("miltertest execution failed");
|
||||
|
||||
print_output_stream("STDOUT", output.stdout);
|
||||
print_output_stream("STDERR", output.stderr);
|
||||
|
||||
milter::shutdown();
|
||||
|
||||
output.status
|
||||
})
|
||||
}
|
||||
|
||||
fn to_miltertest_file_name(file_name: &str) -> OsString {
|
||||
let mut path = PathBuf::from(file_name);
|
||||
path.set_extension("lua");
|
||||
path.into_os_string()
|
||||
}
|
||||
|
||||
fn print_output_stream(name: &str, output: Vec<u8>) {
|
||||
if !output.is_empty() {
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
|
||||
eprintln!("{}:", name);
|
||||
|
||||
if output.ends_with('\n') {
|
||||
eprint!("{}", &output)
|
||||
} else {
|
||||
eprintln!("{}", &output)
|
||||
}
|
||||
}
|
||||
}
|
71
tests/ham_flow.lua
Normal file
71
tests/ham_flow.lua
Normal file
|
@ -0,0 +1,71 @@
|
|||
conn = mt.connect("inet:3783@localhost")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, "client.example.com", "123.123.123.123")
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_CONTINUE)
|
||||
|
||||
local err = mt.helo(conn, "mail.example.com")
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_CONTINUE)
|
||||
|
||||
-- local err = mt.macro(conn, SMFIC_MAIL, "{auth_authen}", "my-auth-authen")
|
||||
-- assert(err == nil, err)
|
||||
|
||||
local err = mt.mailfrom(conn, "from@example.com")
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_CONTINUE)
|
||||
|
||||
local err = mt.rcptto(conn, "to@example.com")
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_CONTINUE)
|
||||
|
||||
-- SMFIC_DATA not exported by miltertest:
|
||||
SMFIC_DATA = string.byte("T")
|
||||
local err = mt.macro(conn, SMFIC_DATA,
|
||||
"i", "B45003F07A",
|
||||
"j", "localhost",
|
||||
"_", "client.example.com [123.123.123.123]",
|
||||
"{tls_version}", "TLSv1.2",
|
||||
"v", "Postfix 3.3.0")
|
||||
assert(err == nil, err)
|
||||
|
||||
local err = mt.data(conn)
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_CONTINUE)
|
||||
|
||||
local err = mt.header(conn, "From", "from@example.com")
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_CONTINUE)
|
||||
|
||||
local err = mt.header(conn, "To", "to@example.com")
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_CONTINUE)
|
||||
|
||||
local err = mt.header(conn, "Subject", "Test message")
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_CONTINUE)
|
||||
|
||||
local err = mt.header(conn, "Message-Id", "<1234567890@example.com>")
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_CONTINUE)
|
||||
|
||||
-- TODO format current date?
|
||||
local err = mt.header(conn, "Date", "Tue, 21 Jan 2020 20:19:22 +0100")
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_CONTINUE)
|
||||
|
||||
local err = mt.eoh(conn)
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_CONTINUE)
|
||||
|
||||
local err = mt.bodystring(conn, "Test message body")
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_CONTINUE)
|
||||
|
||||
local err = mt.eom(conn)
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_ACCEPT)
|
||||
|
||||
local err = mt.disconnect(conn)
|
||||
assert(err == nil, err)
|
20
tests/ham_flow.rs
Normal file
20
tests/ham_flow.rs
Normal file
|
@ -0,0 +1,20 @@
|
|||
mod common;
|
||||
|
||||
use spamassassin_milter::*;
|
||||
|
||||
#[test]
|
||||
fn ham_flow() {
|
||||
let port = 4444;
|
||||
|
||||
let mut builder = Config::builder();
|
||||
builder.set_spamc_args(vec![format!("--port={}", port)]);
|
||||
let config = builder.build();
|
||||
|
||||
let _server = common::spawn_echo_server(port);
|
||||
let miltertest = common::spawn_miltertest_runner(file!());
|
||||
|
||||
run("inet:3783@localhost", config).expect("spamassassin-milter failed");
|
||||
|
||||
let exit_code = miltertest.join().expect("panic in miltertest runner");
|
||||
assert!(exit_code.success(), "miltertest returned error exit code");
|
||||
}
|
23
tests/loopback_connection.lua
Normal file
23
tests/loopback_connection.lua
Normal file
|
@ -0,0 +1,23 @@
|
|||
-- Connection from loopback IP address.
|
||||
|
||||
conn = mt.connect("inet:3030@localhost")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, nil, "127.0.0.1")
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_ACCEPT)
|
||||
|
||||
local err = mt.disconnect(conn)
|
||||
assert(err == nil, err)
|
||||
|
||||
-- Connection from ‘unknown’ IP address (for example, UNIX domain socket).
|
||||
|
||||
conn = mt.connect("inet:3030@localhost")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, nil, "unspec")
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_ACCEPT)
|
||||
|
||||
local err = mt.disconnect(conn)
|
||||
assert(err == nil, err)
|
21
tests/loopback_connection.rs
Normal file
21
tests/loopback_connection.rs
Normal file
|
@ -0,0 +1,21 @@
|
|||
mod common;
|
||||
|
||||
use spamassassin_milter::*;
|
||||
|
||||
#[test]
|
||||
fn loopback_connection() {
|
||||
let config = Config::builder()
|
||||
// TODO spamc_args for echo server
|
||||
.build();
|
||||
|
||||
let _server = common::spawn_echo_server(4444);
|
||||
let miltertest = common::spawn_miltertest_runner(file!());
|
||||
|
||||
run("inet:3030@localhost", config).expect("spamassassin-milter failed");
|
||||
|
||||
let exit_code = miltertest.join().expect("panic in miltertest runner");
|
||||
assert!(exit_code.success(), "miltertest returned error exit code");
|
||||
|
||||
// TODO detect panic in server?
|
||||
// server.join().expect("panic in server");
|
||||
}
|
23
tests/loopback_connection_untrusted.lua
Normal file
23
tests/loopback_connection_untrusted.lua
Normal file
|
@ -0,0 +1,23 @@
|
|||
-- Connection from loopback IP address.
|
||||
|
||||
conn = mt.connect("inet:3030@localhost")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, nil, "127.0.0.1")
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_CONTINUE) -- connection not accepted
|
||||
|
||||
local err = mt.disconnect(conn)
|
||||
assert(err == nil, err)
|
||||
|
||||
-- Connection from ‘unknown’ IP address (for example, UNIX domain socket).
|
||||
|
||||
conn = mt.connect("inet:3030@localhost")
|
||||
assert(conn, "could not open connection")
|
||||
|
||||
local err = mt.conninfo(conn, nil, "unspec")
|
||||
assert(err == nil, err)
|
||||
assert(mt.getreply(conn) == SMFIR_CONTINUE) -- connection not accepted
|
||||
|
||||
local err = mt.disconnect(conn)
|
||||
assert(err == nil, err)
|
18
tests/loopback_connection_untrusted.rs
Normal file
18
tests/loopback_connection_untrusted.rs
Normal file
|
@ -0,0 +1,18 @@
|
|||
mod common;
|
||||
|
||||
use spamassassin_milter::*;
|
||||
|
||||
#[test]
|
||||
fn loopback_connection_untrusted() {
|
||||
let mut builder = Config::builder();
|
||||
builder.set_has_trusted_networks(true); // empty trusted networks, none trusted
|
||||
let config = builder.build();
|
||||
|
||||
let _server = common::spawn_echo_server(4444);
|
||||
let miltertest = common::spawn_miltertest_runner(file!());
|
||||
|
||||
run("inet:3030@localhost", config).expect("spamassassin-milter failed");
|
||||
|
||||
let exit_code = miltertest.join().expect("panic in miltertest runner");
|
||||
assert!(exit_code.success(), "miltertest returned error exit code");
|
||||
}
|
Loading…
Reference in a new issue