Add more tests, revise README

This commit is contained in:
David Bürgin 2020-02-12 09:35:32 +01:00
parent 4ed2133618
commit de915de167
31 changed files with 847 additions and 252 deletions

7
CHANGELOG.md Normal file
View file

@ -0,0 +1,7 @@
# SpamAssassin Milter changelog
**UNRELEASED**
## 0.1.0 (2020-??-??)
Initial release.

View file

@ -18,10 +18,6 @@ libc = "0.2"
milter = "0.2"
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]

230
README.md
View file

@ -1,124 +1,196 @@
# SpamAssassin Milter
This milter applies mail filtering through SpamAssassin server using the spamc
client.
SpamAssassin Milter is a milter application that filters email through
SpamAssassin server using the `spamc` client. It is a light-weight component
that serves to integrate [Apache SpamAssassin] with a milter-capable MTA (mail
server) such as [Postfix]. Its task is thus helping combat spam on email sites.
This program is intended as a replacement for [spamass-milt].
SpamAssassin Milter operates as a milter hooked into the MTAs SMTP protocol
handler. It passes incoming messages to SpamAssassin for analysis, and then
interprets the response from SpamAssassin and applies suggested changes to the
message.
Status: **EXPERIMENTAL/IN DEVELOPMENT**
By default, the following modifications are made:
* Always: Add SpamAssassin headers to the message (headers starting with
`X-Spam-`)
* Spam only: Rewrite headers `Subject` `From` `To`, if requested
* Spam only: Replace message body (and rewrite related headers `MIME-Version`
`Content-Type`, if requested)
Alternatively, messages flagged as spam may be rejected at the SMTP level with
an SMTP error reply code.
Both SpamAssassin and SpamAssassin Milter provide various configuration options
to alter the default behaviour. See below for a discussion of several
configuration and integration approaches.
This application can be used as a replacement for [spamass-milt]; it has a
reduced feature set, but it should be satisfactory for a personal mail server
setup. SpamAssassin Milter has been used in such a setup together with Postfix,
SpamAssassin, and for delivery [Dovecot] with LMTP and the [Sieve] plugin.
[Apache SpamAssassin]: https://spamassassin.apache.org
[Postfix]: http://www.postfix.org
[spamass-milt]: https://savannah.nongnu.org/projects/spamass-milt/
## Operation
SpamAssassin Milter filters email messages through the SpamAssassin server. By
default it applies the following modifications to messages:
* Always: Rewrite SpamAssassin headers
* Spam only: Rewrite rewrite headers Subject From To if necessary
* Spam only: Replace message body (and rewrite related headers MIME-Version
Content-Type if necessary)
TODO describe --reject-spam
[Dovecot]: https://dovecot.org
[Sieve]: https://doc.dovecot.org/configuration_manual/sieve/
## Building
This project uses Cargo as usual. If your distribution does not install a
pkg-config metadata file for the milter library used by SpamAssassin Milter, you
can use the provided milter.pc file. Put it on the pkg-config path as follows:
This project is a Rust package. Build it with Cargo as usual.
As a milter, this package requires the libmilter C library to be available. Be
sure to install the libmilter shared library and header files.
If your distribution does not install a pkg-config metadata file for libmilter,
you can use 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.
Note that until recently `miltertest` had a bad bug that prevents most
integration tests in this package from completing. Make sure you use an
up-to-date version of `miltertest`.
## Usage
**This program is in development.** (I do already use it to filter mail on my
own domain, though.)
Once installed, SpamAssassin Milter can be by invoked as `spamassassin-milter`.
`spamassassin-milter` takes one mandatory argument, namely the listening socket
of the milter (the socket to which the MTA will connect). The socket spec can be
in one of the formats <code>inet:<em>port</em>@<em>host</em></code> or
<code>inet6:<em>port</em>@<em>host</em></code> (IPv6), or
<code>unix:<em>path</em></code>, for a TCP or UNIX domain socket, respectively.
The easiest way of setting up SpamAssassin Milter is to edit the provided
systemd service file spamassassin-milter.service, and install it in
/etc/systemd/system.
There is one required argument, the milter listening socket.
TODO
### dry-run
For first-time users, before deploying SpamAssassin Milter on your mail server,
you may wish to run it with the dry-run option. Combined with the verbose
logging option, this gives good insight in the log about the changes that
SpamAssassin Milter would apply.
For example, the following invocation starts SpamAssassin Milter on port 3000:
```
/usr/sbin/spamassassin-milter --dry-run --verbose inet:3001@localhost
spamassassin-milter inet:3000@localhost
```
The available options and flags can be glimpsed by passing the `-h` flag:
```
spamassassin-milter[8604]: AD3F03F1F3: rewriting header: add header "X-Spam-Checker-Version" [dry-run, not done]
spamassassin-milter[8604]: AD3F03F1F3: rewriting header: add header "X-Spam-Level" [dry-run, not done]
spamassassin-milter[8604]: AD3F03F1F3: rewriting header: add header "X-Spam-Status" [dry-run, not done]
spamassassin-milter[8604]: AD3F03F1F3: finished processing
spamassassin-milter[8604]: 112D03F1F3: rewriting header: replace header "X-Spam-Checker-Version" [dry-run, not done]
spamassassin-milter[8604]: 112D03F1F3: rewriting header: replace header "X-Spam-Status" [dry-run, not done]
spamassassin-milter[8604]: 112D03F1F3: finished processing
spamassassin-milter -h
```
More detailed information can be found in the provided man page
*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.
## Configuration
SpamAssassin Milter is designed as a light-weight glue application with just a
few configuration options; this is intentional, as the SpamAssassin components
are themselves already highly configurable.
SpamAssassin Milter is configured by setting command-line options. All options
have reasonable defaults that work well with a stock installation of
SpamAssassin server (`spamd`) and client (`spamc`). You can get started with
just picking a socket and things should just work. Some integration options are
discussed in subsequent sections.
First-time users may wish to run SpamAssassin Milter with the `--dry-run` option
before going live. Combined with the `--verbose` option, this gives accurate
insight into the changes that SpamAssassin Milter would apply.
```
spamassassin-milter --dry-run --verbose inet:3000@localhost
```
## Integration with SpamAssassin
Integration with SpamAssassin has two components: the SpamAssassin server,
called spamd, running as a daemonised service, and the SpamAssassin client
spamc. SpamAssassin Milter uses spamc to communicate with the SpamAssassin
server.
SpamAssassin Milter must be integrated with two SpamAssassin components: the
SpamAssassin server itself, called `spamd`, which does the actual work, and the
SpamAssassin client `spamc`, which serves as an intermediary between the milter
and the server.
### SpamAssassin configuration
The main SpamAssassin configuration file is /etc/spamassassin/local.conf.
The main SpamAssassin configuration file is `/etc/spamassassin/local.conf`. See
`perldoc Mail::SpamAssassin::Conf` for detailed information.
By default, SpamAssassin creates reports for messages it recognises as spam.
These reports replace the message body, that is, the message body is rewritten
to present a report instead of the original message, and the original message is
relegated to a MIME attachment. SpamAssassin Milter by default applies reports.
If the reports behaviour is not desired, they may be disabled by setting the
parameter `report_safe 0` in the SpamAssassin configuration.
Reports are controlled with the `report_safe` configuration parameter. Disable
reports as follows:
TODO describe reports and disabling reports with report_safe 0
```
report_safe 0
```
### spamc configuration
In addition, body rewriting can also be suppressed on the SpamAssassin Milter
side with the `--preserve-body` flag.
To integrate with spamc, it is recommended to set spamc configuration options in
the spamc configuration file /etc/spamassassin/spamc.conf.
SpamAssassin may also rewrite the Subject and other headers, for example, adding
a prefix \*\*\*Spam\*\*\*. This is not done by default, but may be enabled
with a setting like the following:
For example, the following is the content of a minimal spamc.conf file:
```
rewrite_header Subject ***SPAM***
```
SpamAssassin Milter by default applies header rewriting. Header rewriting can be
suppressed on the SpamAssassin Milter side with the `--preserve-headers` flag.
### `spamc` configuration
`spamc` can be configured by passing it command-line options, or preferably, by
setting the command-line options in the configuration file
`/etc/spamassassin/spamc.conf`.
By default, `spamc` will try to reach SpamAssassin server on the dedicated port
783, so that a stock installation of SpamAssassin should work with SpamAssassin
Milter without further configuration.
If SpamAssassin server listens on a different port or on a UNIX domain socket
instead, set the `--socket` option as appropriate in `spamc.conf`:
```
--socket=/run/spamassassin/spamd.sock
```
This configuration instructs spamc to connect to spamd on a UNIX domain socket
at /run/spamassassin/spamd.sock.
When reports are disabled, the following is a reasonable configuration:
When reports are disabled, it is recommended to use the `--headers` option.
```
--socket=/run/spamassassin/spamd.sock
--headers
```
The `--headers` option is just a shortcut that tells spamd not to transmit the
body back to spamc. This option obviously only makes sense and should only be
used when SpamAssassin reports are disabled.
This option is just a shortcut that causes `spamd` not to transmit the message
body back to `spamc`. (This option obviously only makes sense and should only be
used when SpamAssassin reports are disabled.)
Finally, a pitfall of `spamc` deserves highlighting: `spamc` by default tries to
resist failure to an extent that it will not indicate failure even if it cannot
connect to SpamAssassin server at all (apart from warnings logged to syslog)! If
it cannot connect to the server, it simply echoes what it received, and so masks
the error condition. This mechanism is labelled safe fallback and should be
disabled once the system is set up and working well. Set the following flag:
```
--no-safe-fallback
```
## Integration with Postfix
To integrate with Postfix, ensure the SpamAssassin Milter service is up and
running, and then configure its listening socket in /etc/postfix/main.cf as
running, and then configure its listening socket in `/etc/postfix/main.cf` as
follows:
```
smtpd_milters = inet:localhost:3001
smtpd_milters = inet:localhost:3000
non_smtpd_milters = $smtpd_milters
```
@ -126,7 +198,33 @@ After reloading the Postfix configuration, mail will be processed by
SpamAssassin Milter.
By default, SpamAssassin Milter will accept, that is, wont check messages
coming from connections from localhost, and messages from authenticated senders.
coming from local connections (for example, mail sent locally from the
command-line), and messages from authenticated senders (for example, mail
submitted via a SASL-authenticated channel).
## Integration with mail delivery
A further component that can be useful with SpamAssassin Milter is a
[Sieve]-capable mail delivery agent. A Sieve script can for example look at the
`X-Spam-` SpamAssassin headers of the incoming message, and take action based on
those.
As an example, in case [Dovecot] does mail delivery with [LMTP], enable the
Sieve plugin for the LMTP protocol, then setup a global Sieve script that files
messages flagged as spam into the Junk folder:
```
require "fileinto";
if header :contains "X-Spam-Flag" "YES" {
fileinto "Junk";
stop;
}
```
[Dovecot]: https://dovecot.org
[LMTP]: https://doc.dovecot.org/configuration_manual/protocols/lmtp_server/
[Sieve]: http://sieve.info
## Licence

View file

@ -92,13 +92,13 @@ 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
If this option is not used, the message body of messages flagged as spam is
replaced with the body received from SpamAssassin (as are the values of related
headers
.B MIME-Version
and
.BR Content-Type ,
if necessary)
is replaced with the body received from SpamAssassin.
if necessary).
.TP
.BR \-H ", " \-\-preserve-headers
Suppress rewriting of headers
@ -107,8 +107,8 @@ Suppress rewriting of headers
and
.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.
If this option is not used, these headers of messages flagged as spam will have
their values replaced with the values received from SpamAssassin, if necessary.
.TP
.BR \-r ", " \-\-reject-spam
Reject messages flagged as spam at the SMTP level.

View file

@ -2,7 +2,7 @@
Description=SpamAssassin Milter
[Service]
ExecStart=/usr/sbin/spamassassin-milter inet:3001@localhost
ExecStart=/usr/sbin/spamassassin-milter inet:3000@localhost
[Install]
WantedBy=multi-user.target

View file

@ -124,7 +124,7 @@ fn handle_data(mut ctx: Context<Connection>) -> milter::Result<Status> {
let id = queue_id(&ctx.api)?;
if let Err(e) = client.connect() {
eprintln!("{}: cannot connect to spamc: {}", id, e);
eprintln!("{}: failed to start spamc: {}", id, e);
return Ok(Status::Tempfail);
}

View file

@ -35,6 +35,8 @@ impl Spamc {
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 child = Command::new(Spamc::SPAMC_PROGRAM)
.args(self.spamc_args)
.stdin(Stdio::piped())
@ -52,12 +54,13 @@ impl Process for Spamc {
}
fn finish(&mut self) -> Result<Vec<u8>> {
let output = self.spamc.take().expect("spamc process not started").wait_with_output()?;
let spamc = self.spamc.take().expect("spamc process not started");
let output = spamc.wait_with_output()?;
if output.status.success() {
Ok(output.stdout)
} else {
Err(Error::IoError)
Err(Error::IoError(String::from("spamc returned error exit code")))
}
}
@ -68,7 +71,7 @@ impl Process for Spamc {
impl Drop for Spamc {
fn drop(&mut self) {
// Kill the child: a child process will continue to run even after its
// Kill spamc: a child process will continue to run even after its
// `Child` handle has gone out of scope.
if let Some(spamc) = self.spamc.as_mut() {
let _ = spamc.kill();
@ -80,7 +83,7 @@ pub struct Client {
process: Box<dyn Process>,
sender: String,
recipients: Vec<String>,
headers: HeaderMap<'static>, // 'static keys in canonical form
headers: HeaderMap<'static>, // `'static` keys in canonical form
bytes: usize,
}
@ -99,28 +102,34 @@ impl Client {
self.bytes
}
pub fn add_recipient(&mut self, r: String) {
self.recipients.push(r);
pub fn add_recipient(&mut self, rcpt: String) {
self.recipients.push(rcpt);
}
pub fn connect(&mut self) -> Result<()> {
self.process.connect()
}
// Implementation note: the send operations can all fail with an IO error
// and so return a Result that is then simply unwrapped with `?` in the
// `callbacks` module. That is, in normal circumstances we dont expect this
// to occur, only in abnormal circumstances such as when wrong flags where
// passed to spamc!
pub fn send_envelope_sender(&mut self) -> Result<()> {
let buf = format!("X-Envelope-From: {}\r\n", self.sender);
let writer = self.process.writer();
self.bytes += self.process.writer().write(buf.as_bytes())?;
Ok(self.bytes += writer.write(buf.as_bytes())?)
Ok(())
}
pub fn send_envelope_recipients(&mut self) -> Result<()> {
let buf = format!("X-Envelope-To: {}\r\n", self.recipients.join(",\r\n\t"));
let writer = self.process.writer();
self.bytes += self.process.writer().write(buf.as_bytes())?;
Ok(self.bytes += writer.write(buf.as_bytes())?)
Ok(())
}
pub fn send_forged_received_header(
@ -133,7 +142,6 @@ impl Client {
queue_id: &str,
date_time: &str,
) -> Result<()> {
let protocol = if tls { "ESMTPS" } else { "ESMTP" };
let buf = format!(
"Received: from {} ({})\r\n\
\tby {} ({}) with {} id {};\r\n\
@ -143,15 +151,15 @@ impl Client {
client_name_addr,
my_hostname,
mta,
protocol,
if tls { "ESMTPS" } else { "ESMTP" },
queue_id,
date_time,
self.sender
);
let writer = self.process.writer();
self.bytes += self.process.writer().write(buf.as_bytes())?;
Ok(self.bytes += writer.write(buf.as_bytes())?)
Ok(())
}
pub fn send_header(&mut self, name: &str, value: &str) -> Result<()> {
@ -165,7 +173,7 @@ impl Client {
}
let value = email::ensure_crlf(value);
let buf = format!("{}: {}\r\n", name, &value);
let buf = format!("{}: {}\r\n", name, value);
if let Some(canonical_name) = email::REWRITE_HEADERS.get(name)
.or_else(|| email::REPORT_HEADERS.get(name))
@ -173,21 +181,21 @@ impl Client {
self.headers.insert_if_absent(canonical_name, value);
}
let writer = self.process.writer();
self.bytes += self.process.writer().write(buf.as_bytes())?;
Ok(self.bytes += writer.write(buf.as_bytes())?)
Ok(())
}
pub fn send_eoh(&mut self) -> Result<()> {
let writer = self.process.writer();
self.bytes += self.process.writer().write(b"\r\n")?;
Ok(self.bytes += writer.write(b"\r\n")?)
Ok(())
}
pub fn send_body_chunk(&mut self, bytes: &[u8]) -> Result<()> {
let writer = self.process.writer();
self.bytes += self.process.writer().write(bytes)?;
Ok(self.bytes += writer.write(bytes)?)
Ok(())
}
pub fn process(
@ -199,7 +207,7 @@ impl Client {
let output = match self.process.finish() {
Ok(output) => output,
Err(e) => {
eprintln!("{}: unable to wait for spamc: {}", id, e);
eprintln!("{}: failed to wait for spamc to exit: {}", id, e);
return Ok(Status::Tempfail);
}
};
@ -207,7 +215,7 @@ impl Client {
let email = match Email::parse(&output) {
Ok(email) => email,
Err(e) => {
eprintln!("{}: unable to parse response from spamc: {}", id, e);
eprintln!("{}: {}", id, e);
return Ok(Status::Tempfail);
}
};
@ -245,8 +253,9 @@ fn reject_spam(id: &str, actions: &impl SetErrorReply, config: &Config) -> milte
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![])?;
// These reply codes are the most appropriate according to RFCs 5321 and
// 3463. The text is kept generic and makes no mention of SpamAssassin.
actions.set_error_reply("550", Some("5.7.1"), vec!["Spam message refused"])?;
verbose!(config, "{}: rejected message flagged as spam", id);
Status::Reject
@ -410,7 +419,7 @@ mod tests {
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 config = Default::default();
let client = Client::new(spamc, String::from("sender"));
let status = client.process("id", &actions, &config).unwrap();
@ -432,7 +441,11 @@ mod tests {
assert_eq!(status, Status::Reject);
assert_eq!(
actions.called.borrow().first(),
Some(&Action::SetErrorReply("550".into(), Some("5.7.1".into()), vec![]))
Some(&Action::SetErrorReply(
"550".into(),
Some("5.7.1".into()),
vec!["Spam message refused".into()]
))
);
}
@ -442,7 +455,7 @@ mod tests {
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 config = Default::default();
let mut client = Client::new(spamc, String::from("sender"));
client.send_header("x-spam-level", "*").unwrap();

View file

@ -2,10 +2,8 @@ use ipnet::IpNet;
use once_cell::sync::OnceCell;
use std::{collections::HashSet, net::IpAddr};
/// A builder for [`Config`] objects.
///
/// [`Config`]: struct.Config.html
#[derive(Clone, Debug, Default)]
/// A builder for SpamAssassin Milter configuration objects.
#[derive(Clone, Debug)]
pub struct ConfigBuilder {
has_trusted_networks: bool,
trusted_networks: HashSet<IpNet>,
@ -87,8 +85,25 @@ impl ConfigBuilder {
}
}
impl Default for ConfigBuilder {
fn default() -> Self {
Self {
has_trusted_networks: Default::default(),
trusted_networks: Default::default(),
auth_untrusted: Default::default(),
spamc_args: Default::default(),
max_message_size: 512_000,
dry_run: Default::default(),
reject_spam: Default::default(),
preserve_headers: Default::default(),
preserve_body: Default::default(),
verbose: Default::default(),
}
}
}
/// A configuration object for SpamAssassin Milter.
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug)]
pub struct Config {
has_trusted_networks: bool,
trusted_networks: HashSet<IpNet>,
@ -103,14 +118,8 @@ pub struct Config {
}
impl Config {
/// Returns a new `ConfigBuilder` with default settings.
///
/// [`ConfigBuilder`]: struct.ConfigBuilder.html
pub fn builder() -> ConfigBuilder {
ConfigBuilder {
max_message_size: 512_000,
..Default::default()
}
Default::default()
}
pub fn has_trusted_networks(&self) -> bool {
@ -154,6 +163,12 @@ impl Config {
}
}
impl Default for Config {
fn default() -> Self {
ConfigBuilder::default().build()
}
}
static CONFIG: OnceCell<Config> = OnceCell::new();
pub fn init(config: Config) {

View file

@ -154,10 +154,10 @@ impl<'a> HeaderRewriter<'a> {
pub fn new(original: HeaderMap<'a>, config: &'a Config) -> Self {
Self {
original,
processed: Default::default(),
spam_assassin_mods: Default::default(),
rewrite_mods: Default::default(),
report_mods: Default::default(),
processed: HeaderSet::new(),
spam_assassin_mods: vec![],
rewrite_mods: vec![],
report_mods: vec![],
config,
}
}
@ -340,7 +340,10 @@ mod tests {
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 &[_]]);
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]
@ -350,7 +353,10 @@ mod tests {
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" }));
assert_eq!(
parse_header_line(b"name:\r\n\tvalue"),
Ok(Header { name: "name", value: "\r\n\tvalue" })
);
}
#[test]
@ -388,7 +394,7 @@ mod tests {
#[test]
fn header_rewriter_flags_spam() {
let config = Config::builder().build();
let config = Default::default();
let mut headers = HeaderMap::new();
headers.insert("x-spam-flag", String::from("no"));
@ -400,7 +406,7 @@ mod tests {
#[test]
fn header_rewriter_processes_first_occurrence_only() {
let config = Config::builder().build();
let config = Default::default();
let headers = HeaderMap::new();
let mut rewriter = HeaderRewriter::new(headers, &config);
@ -420,7 +426,7 @@ mod tests {
#[test]
fn header_rewriter_replaces_different_values() {
let config = Config::builder().build();
let config = Default::default();
let mut headers = HeaderMap::new();
headers.insert("x-spam-level", String::from("***"));
headers.insert("x-spam-report", String::from("original"));

View file

@ -6,40 +6,34 @@ use std::{
pub type Result<T> = result::Result<T, Error>;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum Error {
ParseEmail,
IoError,
// Milter(MilterError),
// For our purposes it is enough to record just the error message of I/O
// errors, no need to keep the full `io::Error` around.
IoError(String),
}
// #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
// pub enum MilterError {
// Main,
// Socket,
// }
impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
use Error::*;
match self {
ParseEmail => write!(f, "could not parse email response from spamc"),
_ => write!(f, "TODO"),
ParseEmail => write!(f, "failed to parse email response"),
IoError(msg) => msg.fmt(f),
}
}
}
impl error::Error for Error {
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
// TODO
None
}
}
impl From<io::Error> for Error {
fn from(_: io::Error) -> Self {
Error::IoError
fn from(error: io::Error) -> Self {
Error::IoError(format!("{}", error)) // just record the error message
}
}

View file

@ -42,13 +42,25 @@ pub const VERSION: &str = "0.0.2";
///
/// If execution of the milter fails, an error variant of type `milter::Error`
/// is returned.
///
/// # Examples
///
/// ```no_run
/// use spamassassin_milter::Config;
/// use std::process;
///
/// let socket = "inet:3000@localhost";
/// let config = Config::builder().build();
///
/// if let Err(e) = spamassassin_milter::run(socket, config) {
/// eprintln!("failed to run spamassassin-milter: {}", e);
/// process::exit(1);
/// }
/// ```
pub fn run(socket: &str, config: Config) -> milter::Result<()> {
config::init(config);
eprintln!("{} {} starting", MILTER_NAME, VERSION);
// TODO should return our Result type?
let result = Milter::new(socket)
Milter::new(socket)
.name(MILTER_NAME)
.on_negotiate(negotiate_callback)
.on_connect(connect_callback)
@ -62,9 +74,5 @@ pub fn run(socket: &str, config: Config) -> milter::Result<()> {
.on_eom(eom_callback)
.on_abort(abort_callback)
.on_close(close_callback)
.run();
eprintln!("{} {} shut down", MILTER_NAME, VERSION);
result
.run()
}

View file

@ -14,8 +14,10 @@ const ARG_SOCKET: &str = "SOCKET";
const ARG_SPAMC_ARGS: &str = "SPAMC_ARGS";
fn main() {
let matches = App::new(spamassassin_milter::MILTER_NAME)
.version(spamassassin_milter::VERSION)
use spamassassin_milter::{MILTER_NAME, VERSION};
let matches = App::new(MILTER_NAME)
.version(VERSION)
.arg(Arg::with_name(ARG_AUTH_UNTRUSTED)
.short("a")
.long("auth-untrusted")
@ -40,6 +42,7 @@ fn main() {
.arg(Arg::with_name(ARG_REJECT_SPAM)
.short("r")
.long("reject-spam")
.conflicts_with_all(&[ARG_PRESERVE_BODY, ARG_PRESERVE_HEADERS])
.help("Reject messages flagged as spam"))
.arg(Arg::with_name(ARG_TRUSTED_NETWORKS)
.short("t")
@ -52,8 +55,8 @@ fn main() {
.long("verbose")
.help("Enable verbose operation logging"))
.arg(Arg::with_name(ARG_SOCKET)
.help("Listening socket of the milter")
.required(true))
.required(true)
.help("Listening socket of the milter"))
.arg(Arg::with_name(ARG_SPAMC_ARGS)
.last(true)
.multiple(true)
@ -69,9 +72,16 @@ fn main() {
}
};
if let Err(e) = spamassassin_milter::run(socket, config) {
eprintln!("error: {}", e);
process::exit(1);
eprintln!("{} {} starting", MILTER_NAME, VERSION);
match spamassassin_milter::run(socket, config) {
Ok(_) => {
eprintln!("{} {} shut down", MILTER_NAME, VERSION);
}
Err(e) => {
eprintln!("{} {} terminated with error: {}", MILTER_NAME, VERSION, e);
process::exit(1);
}
}
}
@ -124,7 +134,7 @@ fn build_config(matches: &ArgMatches<'_>) -> Result<Config, String> {
}
if let Some(s) = matches.values_of(ARG_SPAMC_ARGS) {
config.set_spamc_args(s.map(|arg| arg.to_owned()).collect::<Vec<_>>());
config.set_spamc_args(s.map(|arg| arg.to_owned()).collect());
};
Ok(config.build())

View file

@ -0,0 +1,23 @@
-- An authenticated sender is accepted, the message is not processed.
conn = mt.connect("inet:3333@localhost")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.helo(conn, "mail.gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
-- `{auth_authen}` holds the SASL login name, if any.
local err = mt.macro(conn, SMFIC_MAIL, "{auth_authen}", "from@gluet.ch")
assert(err == nil, err)
local err = mt.mailfrom(conn, "from@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_ACCEPT)
local err = mt.disconnect(conn)
assert(err == nil, err)

View file

@ -0,0 +1,16 @@
mod common;
pub use common::*; // `pub` only to silence unused code warnings
use spamassassin_milter::*;
#[test]
fn authenticated_sender() {
let config = Config::builder().build();
let miltertest = spawn_miltertest_runner(file!());
run("inet:3333@localhost", config).expect("milter execution failed");
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
}

View file

@ -8,29 +8,78 @@ use std::{
time::Duration,
};
const MILTERTEST: &str = "miltertest";
pub const SPAMD_PORT: u16 = 3783; // mock port
pub fn spawn_echo_server(port: u16) -> JoinHandle<()> {
pub type HamOrSpam = Result<String, String>;
/// A mock `spamd` server that echoes what it is sent, after applying
/// transformation `f` to the message content and mock-classifying it as ham or
/// spam.
pub fn spawn_mock_spamd_server<F>(port: u16, f: F) -> JoinHandle<()>
where
F: Fn(String) -> HamOrSpam + Send + 'static,
{
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();
// This server handles only a single connection, so that we can `join`
// this thread in the tests and detect panics.
// TODO What if the connection never comes? Timeout?
match listener.accept() {
Ok((mut stream, _)) => {
let mut buf = Vec::new();
stream.read_to_end(&mut buf).unwrap();
stream.write_all(process_message(buf, &f).as_bytes()).unwrap();
stream.shutdown(Shutdown::Write).unwrap();
}
Err(e) => {
panic!("mock spamd server could not open connection: {}", e);
}
}
})
}
// The client/server protocol is here reverse-engineered in a very rudimentary
// fashion, but hopefully sufficient for testing purposes. Both client and
// server send a protocol header containing a content length indication and
// terminated with "\r\n\r\n". The payload is the email message (with CRLF line
// endings).
const SPAMD_PROTOCOL_OK: &str = "SPAMD/1.1 0 EX_OK";
fn process_message<F>(buf: Vec<u8>, f: &F) -> String
where
F: Fn(String) -> HamOrSpam,
{
let mut s = String::from_utf8(buf).unwrap();
// Crude handling of the spamc client protocol: strip off everything before
// and including the first "\r\n\r\n".
let i = s.find("\r\n\r\n").expect("spamc protocol header missing");
s.replace_range(..i + 4, "");
match f(s) {
// Again very basic handling of the spamd server protocol: add a forged
// protocol header terminated with "\r\n\r\n".
Ok(ham) => format!(
"{}\r\nContent-length: {}\r\nSpam: False ; 4.0 / 5.0\r\n\r\n{}",
SPAMD_PROTOCOL_OK,
ham.len(),
ham
),
Err(spam) => format!(
"{}\r\nContent-length: {}\r\nSpam: True ; 6.0 / 5.0\r\n\r\n{}",
SPAMD_PROTOCOL_OK,
spam.len(),
spam
),
}
}
const MILTERTEST: &str = "miltertest";
pub fn spawn_miltertest_runner(test_file_name: &str) -> JoinHandle<ExitStatus> {
let _timeout_thread = thread::spawn(|| {
thread::sleep(Duration::from_secs(15));

View file

@ -1,31 +1,29 @@
conn = mt.connect("inet:3783@localhost")
-- Happy path processing of an ordinary ham (not spam) message.
conn = mt.connect("inet:3333@localhost")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.example.com", "123.123.123.123")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.helo(conn, "mail.example.com")
local err = mt.helo(conn, "mail.gluet.ch")
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")
local err = mt.mailfrom(conn, "from@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.rcptto(conn, "to@example.com")
local err = mt.rcptto(conn, "to@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
-- SMFIC_DATA not exported by miltertest:
SMFIC_DATA = string.byte("T")
SMFIC_DATA = string.byte("T") -- SMFIC_DATA not exported by miltertest
local err = mt.macro(conn, SMFIC_DATA,
"i", "B45003F07A",
"i", "1234567ABC",
"j", "localhost",
"_", "client.example.com [123.123.123.123]",
"_", "client.gluet.ch [123.123.123.123]",
"{tls_version}", "TLSv1.2",
"v", "Postfix 3.3.0")
assert(err == nil, err)
@ -34,24 +32,19 @@ local err = mt.data(conn)
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "From", "from@example.com")
local err = mt.header(conn, "From", "from@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "To", "to@example.com")
local err = mt.header(conn, "To", "to@gluet.ch")
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>")
local err = mt.header(conn, "Message-ID", string.format("<%06d@gluet.ch>", math.random(999999)))
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")
local err = mt.header(conn, "Date", os.date("%a, %d %b %Y %H:%M:%S %Z"))
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)

View file

@ -1,20 +1,21 @@
mod common;
pub use common::*; // `pub` only to silence unused code warnings
use spamassassin_milter::*;
#[test]
fn ham_flow() {
let port = 4444;
let mut builder = Config::builder();
builder.set_spamc_args(vec![format!("--port={}", port)]);
builder.set_spamc_args(vec![format!("--port={}", SPAMD_PORT)]);
let config = builder.build();
let _server = common::spawn_echo_server(port);
let miltertest = common::spawn_miltertest_runner(file!());
let server = spawn_mock_spamd_server(SPAMD_PORT, Ok);
let miltertest = spawn_miltertest_runner(file!());
run("inet:3783@localhost", config).expect("spamassassin-milter failed");
run("inet:3333@localhost", config).expect("milter execution failed");
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
server.join().expect("panic in mock spamd server");
}

66
tests/live.lua Normal file
View file

@ -0,0 +1,66 @@
-- Live test against an actual SpamAssassin server.
conn = mt.connect("inet:3333@localhost")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.helo(conn, "mail.gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.mailfrom(conn, "from@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.rcptto(conn, "to@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
SMFIC_DATA = string.byte("T") -- SMFIC_DATA not exported by miltertest
local err = mt.macro(conn, SMFIC_DATA,
"i", "1234567ABC",
"j", "localhost",
"_", "client.gluet.ch [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@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "To", "to@gluet.ch")
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", string.format("<%06d@gluet.ch>", math.random(999999)))
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "Date", os.date("%a, %d %b %Y %H:%M:%S %Z"))
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)
-- This is the magic GTUBE value, which makes this message certain spam.
-- See https://spamassassin.apache.org/gtube/.
local err = mt.bodystring(conn, "XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X")
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)

22
tests/live.rs Normal file
View file

@ -0,0 +1,22 @@
mod common;
pub use common::*; // `pub` only to silence unused code warnings
use spamassassin_milter::*;
/// Runs a live test against a real SpamAssassin server instance. This test is
/// run on demand, as SpamAssassin will actually analyse the input, and do DNS
/// queries etc.
#[test]
#[ignore] // use option `--include-ignored` to run
fn live() {
// Without `spamc_args` set, `spamc` will try to connect to the default
// `spamd` port 783 (see also /etc/services).
let config = Config::builder().build();
let miltertest = spawn_miltertest_runner(file!());
run("inet:3333@localhost", config).expect("milter execution failed");
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
}

View file

@ -1,6 +1,6 @@
-- Connection from loopback IP address.
-- 1) A connection from the loopback IP address is accepted.
conn = mt.connect("inet:3030@localhost")
conn = mt.connect("inet:3333@localhost")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, nil, "127.0.0.1")
@ -10,9 +10,10 @@ 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).
-- 2) A connection from an unknown IP address (for example, from a UNIX
-- domain socket) is also accepted.
conn = mt.connect("inet:3030@localhost")
conn = mt.connect("inet:3333@localhost")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, nil, "unspec")

View file

@ -1,21 +1,16 @@
mod common;
pub use common::*; // `pub` only to silence unused code warnings
use spamassassin_milter::*;
#[test]
fn loopback_connection() {
let config = Config::builder()
// TODO spamc_args for echo server
.build();
let config = Config::builder().build();
let _server = common::spawn_echo_server(4444);
let miltertest = common::spawn_miltertest_runner(file!());
let miltertest = spawn_miltertest_runner(file!());
run("inet:3030@localhost", config).expect("spamassassin-milter failed");
run("inet:3333@localhost", config).expect("milter execution 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");
}

View file

@ -1,23 +0,0 @@
-- 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)

View file

@ -1,18 +0,0 @@
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");
}

View file

@ -0,0 +1,79 @@
-- Transfer message body chunks until more than some max message size bytes
-- have been written, then skip the rest of the message (oversized message
-- wont be processed by SpamAssassin anyway, so it is futile to send the whole
-- message).
conn = mt.connect("inet:3333@localhost")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.helo(conn, "mail.gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.mailfrom(conn, "from@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.rcptto(conn, "to@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
SMFIC_DATA = string.byte("T") -- SMFIC_DATA not exported by miltertest
local err = mt.macro(conn, SMFIC_DATA,
"i", "1234567ABC",
"j", "localhost",
"_", "client.gluet.ch [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@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "To", "to@gluet.ch")
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", string.format("<%06d@gluet.ch>", math.random(999999)))
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "Date", os.date("%a, %d %b %Y %H:%M:%S %Z"))
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)
-- At this point still below the size limit …
local err = mt.bodystring(conn, "Test message body")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
-- … after sending the following, were past the limit and skip.
local err = mt.bodystring(conn, [[
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
]])
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_SKIP)
local err = mt.eom(conn)
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_ACCEPT)
local err = mt.disconnect(conn)
assert(err == nil, err)

View file

@ -0,0 +1,22 @@
mod common;
pub use common::*; // `pub` only to silence unused code warnings
use spamassassin_milter::*;
#[test]
fn skip_large_message() {
let mut builder = Config::builder();
builder.set_max_message_size(512);
builder.set_spamc_args(vec![format!("--port={}", SPAMD_PORT)]);
let config = builder.build();
let server = spawn_mock_spamd_server(SPAMD_PORT, Ok);
let miltertest = spawn_miltertest_runner(file!());
run("inet:3333@localhost", config).expect("milter execution failed");
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
server.join().expect("panic in mock spamd server");
}

71
tests/spam_flow.lua Normal file
View file

@ -0,0 +1,71 @@
-- Happy path processing of a spam message.
conn = mt.connect("inet:3333@localhost")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.helo(conn, "mail.gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.mailfrom(conn, "from@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.rcptto(conn, "to@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
SMFIC_DATA = string.byte("T") -- SMFIC_DATA not exported by miltertest
local err = mt.macro(conn, SMFIC_DATA,
"i", "1234567ABC",
"j", "localhost",
"_", "client.gluet.ch [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@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "To", "to@gluet.ch")
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", string.format("<%06d@gluet.ch>", math.random(999999)))
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "Date", os.date("%a, %d %b %Y %H:%M:%S %Z"))
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "X-Spam-Checker-Version", "BogusChecker 1.0.0")
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, "You have just won a billion dollars!!!!")
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)
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_BODYCHANGE))
local err = mt.disconnect(conn)
assert(err == nil, err)

30
tests/spam_flow.rs Normal file
View file

@ -0,0 +1,30 @@
mod common;
pub use common::*; // `pub` only to silence unused code warnings
use spamassassin_milter::*;
#[test]
fn spam_flow() {
let mut builder = Config::builder();
builder.set_spamc_args(vec![format!("--port={}", SPAMD_PORT)]);
let config = builder.build();
let server = spawn_mock_spamd_server(SPAMD_PORT, |s| {
Err(
s.replacen("Subject: Test message\r\n", "Subject: [SPAM] Test message\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\
\r\n", 1)
)
});
let miltertest = spawn_miltertest_runner(file!());
run("inet:3333@localhost", config).expect("milter execution failed");
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
server.join().expect("panic in mock spamd server");
}

View file

@ -0,0 +1,68 @@
-- When no spamd server is available, spamc fails to connect.
conn = mt.connect("inet:3333@localhost")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.helo(conn, "mail.gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.mailfrom(conn, "from@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.rcptto(conn, "to@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
SMFIC_DATA = string.byte("T") -- SMFIC_DATA not exported by miltertest
local err = mt.macro(conn, SMFIC_DATA,
"i", "1234567ABC",
"j", "localhost",
"_", "client.gluet.ch [123.123.123.123]",
"{tls_version}", "TLSv1.2",
"v", "Postfix 3.3.0")
assert(err == nil, err)
-- If spamc cannot connect to spamd, it will retry several times. That is why
-- this test can proceed all the way to the `eom` stage where the failure
-- finally surfaces.
local err = mt.data(conn)
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "From", "from@gluet.ch")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "To", "to@gluet.ch")
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", string.format("<%06d@gluet.ch>", math.random(999999)))
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "Date", os.date("%a, %d %b %Y %H:%M:%S %Z"))
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_TEMPFAIL)
local err = mt.disconnect(conn)
assert(err == nil, err)

View file

@ -0,0 +1,24 @@
mod common;
pub use common::*; // `pub` only to silence unused code warnings
use spamassassin_milter::*;
#[test]
fn spamc_connection_error() {
let mut builder = Config::builder();
// spamc always works even if it cannot actually reach spamd!
// `--no-safe-fallback` prevents this masking of connection errors.
// TODO Also try with non-existing flag, behaviour is different?
builder.set_spamc_args(vec![
String::from("--no-safe-fallback"),
format!("--port={}", SPAMD_PORT),
]);
let config = builder.build();
let miltertest = spawn_miltertest_runner(file!());
run("inet:3333@localhost", config).expect("milter execution failed");
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
}

View file

@ -0,0 +1,11 @@
-- A connection from a trusted network is accepted.
conn = mt.connect("inet:3333@localhost")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_ACCEPT)
local err = mt.disconnect(conn)
assert(err == nil, err)

View file

@ -0,0 +1,18 @@
mod common;
pub use common::*; // `pub` only to silence unused code warnings
use spamassassin_milter::*;
#[test]
fn trusted_network_connection() {
let mut builder = Config::builder();
builder.add_trusted_network("123.120.0.0/14".parse().unwrap());
let config = builder.build();
let miltertest = spawn_miltertest_runner(file!());
run("inet:3333@localhost", config).expect("milter execution failed");
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
}