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