spamassassin_milter/
config.rs

1// SpamAssassin Milter – milter for spam filtering with SpamAssassin
2// Copyright © 2020–2023 David Bürgin <dbuergin@gluet.ch>
3//
4// This program is free software: you can redistribute it and/or modify it under
5// the terms of the GNU General Public License as published by the Free Software
6// Foundation, either version 3 of the License, or (at your option) any later
7// version.
8//
9// This program is distributed in the hope that it will be useful, but WITHOUT
10// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12// details.
13//
14// You should have received a copy of the GNU General Public License along with
15// this program. If not, see <https://www.gnu.org/licenses/>.
16
17use ipnet::IpNet;
18use std::{collections::HashSet, net::IpAddr};
19
20/// A builder for SpamAssassin Milter configuration objects.
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct ConfigBuilder {
23    use_trusted_networks: bool,
24    trusted_networks: HashSet<IpNet>,
25    auth_untrusted: bool,
26    spamc_args: Vec<String>,
27    max_message_size: usize,
28    dry_run: bool,
29    reject_spam: bool,
30    reply_code: String,
31    reply_status_code: String,
32    reply_text: String,
33    preserve_headers: bool,
34    preserve_body: bool,
35    verbose: bool,
36}
37
38impl ConfigBuilder {
39    pub fn use_trusted_networks(mut self, value: bool) -> Self {
40        self.use_trusted_networks = value;
41        self
42    }
43
44    pub fn trusted_network(mut self, net: IpNet) -> Self {
45        self.use_trusted_networks = true;
46        self.trusted_networks.insert(net);
47        self
48    }
49
50    pub fn auth_untrusted(mut self, value: bool) -> Self {
51        self.auth_untrusted = value;
52        self
53    }
54
55    pub fn spamc_args<I, S>(mut self, args: I) -> Self
56    where
57        I: IntoIterator<Item = S>,
58        S: AsRef<str>,
59    {
60        self.spamc_args.extend(args.into_iter().map(|a| a.as_ref().to_owned()));
61        self
62    }
63
64    pub fn max_message_size(mut self, value: usize) -> Self {
65        self.max_message_size = value;
66        self
67    }
68
69    pub fn dry_run(mut self, value: bool) -> Self {
70        self.dry_run = value;
71        self
72    }
73
74    pub fn reject_spam(mut self, value: bool) -> Self {
75        self.reject_spam = value;
76        self
77    }
78
79    pub fn reply_code(mut self, value: String) -> Self {
80        self.reply_code = value;
81        self
82    }
83
84    pub fn reply_status_code(mut self, value: String) -> Self {
85        self.reply_status_code = value;
86        self
87    }
88
89    pub fn reply_text(mut self, value: String) -> Self {
90        self.reply_text = value;
91        self
92    }
93
94    pub fn preserve_headers(mut self, value: bool) -> Self {
95        self.preserve_headers = value;
96        self
97    }
98
99    pub fn preserve_body(mut self, value: bool) -> Self {
100        self.preserve_body = value;
101        self
102    }
103
104    pub fn verbose(mut self, value: bool) -> Self {
105        self.verbose = value;
106        self
107    }
108
109    pub fn build(self) -> Config {
110        // These invariants are enforced in `main`. In a future revision,
111        // consider replacing the assertions with a `Result` return type.
112        assert!(
113            self.use_trusted_networks || self.trusted_networks.is_empty(),
114            "trusted networks present but not used"
115        );
116        assert!(
117            is_valid_reply_code(&self.reply_code)
118                && is_valid_reply_code(&self.reply_status_code)
119                && self.reply_code.as_bytes()[0] == self.reply_status_code.as_bytes()[0],
120            "invalid or incompatible reply codes"
121        );
122
123        Config {
124            use_trusted_networks: self.use_trusted_networks,
125            trusted_networks: self.trusted_networks,
126            auth_untrusted: self.auth_untrusted,
127            spamc_args: self.spamc_args,
128            max_message_size: self.max_message_size,
129            dry_run: self.dry_run,
130            reject_spam: self.reject_spam,
131            reply_code: self.reply_code,
132            reply_status_code: self.reply_status_code,
133            reply_text: self.reply_text,
134            preserve_headers: self.preserve_headers,
135            preserve_body: self.preserve_body,
136            verbose: self.verbose,
137        }
138    }
139}
140
141fn is_valid_reply_code(s: &str) -> bool {
142    s.starts_with('4') || s.starts_with('5')
143}
144
145impl Default for ConfigBuilder {
146    fn default() -> Self {
147        Self {
148            use_trusted_networks: Default::default(),
149            trusted_networks: Default::default(),
150            auth_untrusted: Default::default(),
151            spamc_args: Default::default(),
152            max_message_size: 512_000,  // same as in `spamc`
153            dry_run: Default::default(),
154            reject_spam: Default::default(),
155            // This reply code and enhanced status code are the most appropriate
156            // choices according to RFCs 5321 and 3463.
157            reply_code: "550".into(),
158            reply_status_code: "5.7.1".into(),
159            // Generic reply text that makes no mention of SpamAssassin.
160            reply_text: "Spam message refused".into(),
161            preserve_headers: Default::default(),
162            preserve_body: Default::default(),
163            verbose: Default::default(),
164        }
165    }
166}
167
168/// A configuration object for SpamAssassin Milter.
169#[derive(Clone, Debug, Eq, PartialEq)]
170pub struct Config {
171    use_trusted_networks: bool,
172    trusted_networks: HashSet<IpNet>,
173    auth_untrusted: bool,
174    spamc_args: Vec<String>,
175    max_message_size: usize,
176    dry_run: bool,
177    reject_spam: bool,
178    reply_code: String,
179    reply_status_code: String,
180    reply_text: String,
181    preserve_headers: bool,
182    preserve_body: bool,
183    verbose: bool,
184}
185
186impl Config {
187    pub fn builder() -> ConfigBuilder {
188        Default::default()
189    }
190
191    pub fn use_trusted_networks(&self) -> bool {
192        self.use_trusted_networks
193    }
194
195    pub fn is_in_trusted_networks(&self, ip: &IpAddr) -> bool {
196        self.trusted_networks.iter().any(|n| n.contains(ip))
197    }
198
199    pub fn auth_untrusted(&self) -> bool {
200        self.auth_untrusted
201    }
202
203    pub fn spamc_args(&self) -> &[String] {
204        &self.spamc_args
205    }
206
207    pub fn max_message_size(&self) -> usize {
208        self.max_message_size
209    }
210
211    pub fn dry_run(&self) -> bool {
212        self.dry_run
213    }
214
215    pub fn reject_spam(&self) -> bool {
216        self.reject_spam
217    }
218
219    pub fn reply_code(&self) -> &str {
220        &self.reply_code
221    }
222
223    pub fn reply_status_code(&self) -> &str {
224        &self.reply_status_code
225    }
226
227    pub fn reply_text(&self) -> &str {
228        &self.reply_text
229    }
230
231    pub fn preserve_headers(&self) -> bool {
232        self.preserve_headers
233    }
234
235    pub fn preserve_body(&self) -> bool {
236        self.preserve_body
237    }
238
239    pub fn verbose(&self) -> bool {
240        self.verbose
241    }
242}
243
244impl Default for Config {
245    fn default() -> Self {
246        ConfigBuilder::default().build()
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn trusted_networks_config() {
256        let config = Config::builder()
257            .trusted_network("127.0.0.1/8".parse().unwrap())
258            .build();
259
260        assert!(config.use_trusted_networks());
261        assert!(config.is_in_trusted_networks(&"127.0.0.1".parse().unwrap()));
262        assert!(!config.is_in_trusted_networks(&"10.1.0.1".parse().unwrap()));
263    }
264
265    #[test]
266    fn spamc_args_extends_args() {
267        let config = Config::builder()
268            .spamc_args(["-p", "3030"])
269            .spamc_args(["-x"])
270            .build();
271
272        assert_eq!(
273            config.spamc_args(),
274            &[String::from("-p"), String::from("3030"), String::from("-x")],
275        );
276    }
277}