diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..9c2a8e1
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,7 @@
+# SpamAssassin Milter changelog
+
+**UNRELEASED**
+
+## 0.1.0 (2020-??-??)
+
+Initial release.
diff --git a/Cargo.toml b/Cargo.toml
index d0bf8ea..89afbd5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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]
diff --git a/README.md b/README.md
index f775ae4..4714a4a 100644
--- a/README.md
+++ b/README.md
@@ -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 MTA’s 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 inet:port@host
or
+inet6:port@host
(IPv6), or
+unix:path
, 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, won’t 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
diff --git a/spamassassin-milter.8 b/spamassassin-milter.8
index 09509ee..89f01f7 100644
--- a/spamassassin-milter.8
+++ b/spamassassin-milter.8
@@ -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.
diff --git a/spamassassin-milter.service b/spamassassin-milter.service
index 603ba16..3e88c09 100644
--- a/spamassassin-milter.service
+++ b/spamassassin-milter.service
@@ -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
diff --git a/src/callbacks.rs b/src/callbacks.rs
index 647f121..66e188c 100644
--- a/src/callbacks.rs
+++ b/src/callbacks.rs
@@ -124,7 +124,7 @@ fn handle_data(mut ctx: Context) -> milter::Result {
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);
}
diff --git a/src/client.rs b/src/client.rs
index 55fe494..c568e3e 100644
--- a/src/client.rs
+++ b/src/client.rs
@@ -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> {
- 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,
sender: String,
recipients: Vec,
- 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 don’t 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();
diff --git a/src/config.rs b/src/config.rs
index 1f00926..268e68b 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -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,
@@ -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,
@@ -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 = OnceCell::new();
pub fn init(config: Config) {
diff --git a/src/email.rs b/src/email.rs
index 0802904..c0603d7 100644
--- a/src/email.rs
+++ b/src/email.rs
@@ -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"));
diff --git a/src/error.rs b/src/error.rs
index fb51101..f13c842 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -6,40 +6,34 @@ use std::{
pub type Result = result::Result;
-#[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 for Error {
- fn from(_: io::Error) -> Self {
- Error::IoError
+ fn from(error: io::Error) -> Self {
+ Error::IoError(format!("{}", error)) // just record the error message
}
}
diff --git a/src/lib.rs b/src/lib.rs
index 57df309..b0acb4f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -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()
}
diff --git a/src/main.rs b/src/main.rs
index fa00f51..a52fa74 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -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 {
}
if let Some(s) = matches.values_of(ARG_SPAMC_ARGS) {
- config.set_spamc_args(s.map(|arg| arg.to_owned()).collect::>());
+ config.set_spamc_args(s.map(|arg| arg.to_owned()).collect());
};
Ok(config.build())
diff --git a/tests/authenticated_sender.lua b/tests/authenticated_sender.lua
new file mode 100644
index 0000000..8fdf570
--- /dev/null
+++ b/tests/authenticated_sender.lua
@@ -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)
diff --git a/tests/authenticated_sender.rs b/tests/authenticated_sender.rs
new file mode 100644
index 0000000..c7a510b
--- /dev/null
+++ b/tests/authenticated_sender.rs
@@ -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");
+}
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index 280bd67..8af82e9 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -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;
+
+/// 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(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(buf: Vec, 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 {
let _timeout_thread = thread::spawn(|| {
thread::sleep(Duration::from_secs(15));
diff --git a/tests/ham_flow.lua b/tests/ham_flow.lua
index 5d9a1d9..3dec633 100644
--- a/tests/ham_flow.lua
+++ b/tests/ham_flow.lua
@@ -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)
diff --git a/tests/ham_flow.rs b/tests/ham_flow.rs
index 4df4d8a..e7c5d79 100644
--- a/tests/ham_flow.rs
+++ b/tests/ham_flow.rs
@@ -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");
}
diff --git a/tests/live.lua b/tests/live.lua
new file mode 100644
index 0000000..bcc0dff
--- /dev/null
+++ b/tests/live.lua
@@ -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)
diff --git a/tests/live.rs b/tests/live.rs
new file mode 100644
index 0000000..e7a8d84
--- /dev/null
+++ b/tests/live.rs
@@ -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");
+}
diff --git a/tests/loopback_connection.lua b/tests/loopback_connection.lua
index e2470dc..f7e4652 100644
--- a/tests/loopback_connection.lua
+++ b/tests/loopback_connection.lua
@@ -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")
diff --git a/tests/loopback_connection.rs b/tests/loopback_connection.rs
index 6940e6c..70b0e4c 100644
--- a/tests/loopback_connection.rs
+++ b/tests/loopback_connection.rs
@@ -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");
}
diff --git a/tests/loopback_connection_untrusted.lua b/tests/loopback_connection_untrusted.lua
deleted file mode 100644
index de1c25a..0000000
--- a/tests/loopback_connection_untrusted.lua
+++ /dev/null
@@ -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)
diff --git a/tests/loopback_connection_untrusted.rs b/tests/loopback_connection_untrusted.rs
deleted file mode 100644
index 808ef8e..0000000
--- a/tests/loopback_connection_untrusted.rs
+++ /dev/null
@@ -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");
-}
diff --git a/tests/skip_large_message.lua b/tests/skip_large_message.lua
new file mode 100644
index 0000000..613e20e
--- /dev/null
+++ b/tests/skip_large_message.lua
@@ -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
+-- won’t 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, we’re 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)
diff --git a/tests/skip_large_message.rs b/tests/skip_large_message.rs
new file mode 100644
index 0000000..d8e71f4
--- /dev/null
+++ b/tests/skip_large_message.rs
@@ -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");
+}
diff --git a/tests/spam_flow.lua b/tests/spam_flow.lua
new file mode 100644
index 0000000..8f45399
--- /dev/null
+++ b/tests/spam_flow.lua
@@ -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)
diff --git a/tests/spam_flow.rs b/tests/spam_flow.rs
new file mode 100644
index 0000000..d1a1244
--- /dev/null
+++ b/tests/spam_flow.rs
@@ -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");
+}
diff --git a/tests/spamc_connection_error.lua b/tests/spamc_connection_error.lua
new file mode 100644
index 0000000..1b81af7
--- /dev/null
+++ b/tests/spamc_connection_error.lua
@@ -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)
diff --git a/tests/spamc_connection_error.rs b/tests/spamc_connection_error.rs
new file mode 100644
index 0000000..d3ec417
--- /dev/null
+++ b/tests/spamc_connection_error.rs
@@ -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");
+}
diff --git a/tests/trusted_network_connection.lua b/tests/trusted_network_connection.lua
new file mode 100644
index 0000000..62edda5
--- /dev/null
+++ b/tests/trusted_network_connection.lua
@@ -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)
diff --git a/tests/trusted_network_connection.rs b/tests/trusted_network_connection.rs
new file mode 100644
index 0000000..01b6711
--- /dev/null
+++ b/tests/trusted_network_connection.rs
@@ -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");
+}