async_smtp/
extension.rs

1//! ESMTP features
2
3use crate::authentication::Mechanism;
4use crate::error::Error;
5use crate::response::Response;
6use crate::util::XText;
7use std::collections::HashSet;
8use std::fmt::{self, Display, Formatter};
9use std::net::{Ipv4Addr, Ipv6Addr};
10use std::result::Result;
11
12/// Client identifier, the parameter to `EHLO`
13#[derive(PartialEq, Eq, Clone, Debug)]
14pub enum ClientId {
15    /// A fully-qualified domain name
16    Domain(String),
17    /// An IPv4 address
18    Ipv4(Ipv4Addr),
19    /// An IPv6 address
20    Ipv6(Ipv6Addr),
21}
22
23impl Default for ClientId {
24    fn default() -> Self {
25        // The most compatible address.
26        //
27        // It passes Postfix checks
28        // ```
29        // smtpd_helo_restrictions = reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, reject_unknown_helo_hostname
30        // smtpd_helo_required = yes
31        // smtpd_delay_reject = no
32        // ```
33        Self::Ipv4(Ipv4Addr::new(127, 0, 0, 1))
34    }
35}
36
37impl Display for ClientId {
38    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
39        match *self {
40            ClientId::Domain(ref value) => f.write_str(value),
41            ClientId::Ipv4(ref value) => write!(f, "[{value}]"),
42            ClientId::Ipv6(ref value) => write!(f, "[IPv6:{value}]"),
43        }
44    }
45}
46
47impl ClientId {
48    /// Creates a new `ClientId` from a fully qualified domain name
49    pub fn new(domain: String) -> ClientId {
50        ClientId::Domain(domain)
51    }
52}
53
54/// Supported ESMTP keywords
55#[derive(PartialEq, Eq, Hash, Copy, Clone, Debug)]
56pub enum Extension {
57    /// PIPELINING keyword
58    ///
59    /// RFC 2920: <https://tools.ietf.org/html/rfc2920>
60    Pipelining,
61    /// 8BITMIME keyword
62    ///
63    /// RFC 6152: <https://tools.ietf.org/html/rfc6152>
64    EightBitMime,
65    /// SMTPUTF8 keyword
66    ///
67    /// RFC 6531: <https://tools.ietf.org/html/rfc6531>
68    SmtpUtfEight,
69    /// STARTTLS keyword
70    ///
71    /// RFC 2487: <https://tools.ietf.org/html/rfc2487>
72    StartTls,
73    /// AUTH mechanism
74    Authentication(Mechanism),
75}
76
77impl Display for Extension {
78    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
79        match *self {
80            Extension::Pipelining => write!(f, "PIPELINING"),
81            Extension::EightBitMime => write!(f, "8BITMIME"),
82            Extension::SmtpUtfEight => write!(f, "SMTPUTF8"),
83            Extension::StartTls => write!(f, "STARTTLS"),
84            Extension::Authentication(ref mechanism) => write!(f, "AUTH {mechanism}"),
85        }
86    }
87}
88
89/// Contains information about an SMTP server
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct ServerInfo {
92    /// Server name
93    ///
94    /// The name given in the server banner
95    pub name: String,
96    /// ESMTP features supported by the server
97    ///
98    /// It contains the features supported by the server and known by the `Extension` module.
99    pub features: HashSet<Extension>,
100}
101
102impl Display for ServerInfo {
103    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
104        write!(
105            f,
106            "{} with {}",
107            self.name,
108            if self.features.is_empty() {
109                "no supported features".to_string()
110            } else {
111                format!("{:?}", self.features)
112            }
113        )
114    }
115}
116
117impl ServerInfo {
118    /// Parses a EHLO response to create a `ServerInfo`
119    pub fn from_response(response: &Response) -> Result<ServerInfo, Error> {
120        let name = match response.first_word() {
121            Some(name) => name,
122            None => return Err(Error::ResponseParsing("Could not read server name")),
123        };
124
125        let mut features: HashSet<Extension> = HashSet::new();
126
127        for line in response.message.as_slice() {
128            if line.is_empty() {
129                continue;
130            }
131
132            let split: Vec<&str> = line.split_whitespace().collect();
133            match split.first().copied() {
134                Some("PIPELINING") => {
135                    features.insert(Extension::Pipelining);
136                }
137                Some("8BITMIME") => {
138                    features.insert(Extension::EightBitMime);
139                }
140                Some("SMTPUTF8") => {
141                    features.insert(Extension::SmtpUtfEight);
142                }
143                Some("STARTTLS") => {
144                    features.insert(Extension::StartTls);
145                }
146                Some("AUTH") => {
147                    for &mechanism in split.iter().skip(1) {
148                        match mechanism {
149                            "PLAIN" => {
150                                features.insert(Extension::Authentication(Mechanism::Plain));
151                            }
152                            "LOGIN" => {
153                                features.insert(Extension::Authentication(Mechanism::Login));
154                            }
155                            "XOAUTH2" => {
156                                features.insert(Extension::Authentication(Mechanism::Xoauth2));
157                            }
158                            _ => (),
159                        }
160                    }
161                }
162                _ => (),
163            };
164        }
165
166        Ok(ServerInfo {
167            name: name.to_string(),
168            features,
169        })
170    }
171
172    /// Checks if the server supports an ESMTP feature
173    pub fn supports_feature(&self, keyword: Extension) -> bool {
174        self.features.contains(&keyword)
175    }
176
177    /// Checks if the server supports an ESMTP feature
178    pub fn supports_auth_mechanism(&self, mechanism: Mechanism) -> bool {
179        self.features
180            .contains(&Extension::Authentication(mechanism))
181    }
182}
183
184/// A `MAIL FROM` extension parameter
185#[derive(PartialEq, Eq, Clone, Debug)]
186pub enum MailParameter {
187    /// `BODY` parameter
188    Body(MailBodyParameter),
189    /// `SIZE` parameter
190    Size(usize),
191    /// `SMTPUTF8` parameter
192    SmtpUtfEight,
193    /// Custom parameter
194    Other {
195        /// Parameter keyword
196        keyword: String,
197        /// Parameter value
198        value: Option<String>,
199    },
200}
201
202impl Display for MailParameter {
203    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
204        match *self {
205            MailParameter::Body(ref value) => write!(f, "BODY={value}"),
206            MailParameter::Size(size) => write!(f, "SIZE={size}"),
207            MailParameter::SmtpUtfEight => f.write_str("SMTPUTF8"),
208            MailParameter::Other {
209                ref keyword,
210                value: Some(ref value),
211            } => write!(f, "{}={}", keyword, XText(value)),
212            MailParameter::Other {
213                ref keyword,
214                value: None,
215            } => f.write_str(keyword),
216        }
217    }
218}
219
220/// Values for the `BODY` parameter to `MAIL FROM`
221#[derive(PartialEq, Eq, Clone, Debug, Copy)]
222pub enum MailBodyParameter {
223    /// `7BIT`
224    SevenBit,
225    /// `8BITMIME`
226    EightBitMime,
227}
228
229impl Display for MailBodyParameter {
230    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
231        match *self {
232            MailBodyParameter::SevenBit => f.write_str("7BIT"),
233            MailBodyParameter::EightBitMime => f.write_str("8BITMIME"),
234        }
235    }
236}
237
238/// A `RCPT TO` extension parameter
239#[derive(PartialEq, Eq, Clone, Debug)]
240pub enum RcptParameter {
241    /// Custom parameter
242    Other {
243        /// Parameter keyword
244        keyword: String,
245        /// Parameter value
246        value: Option<String>,
247    },
248}
249
250impl Display for RcptParameter {
251    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
252        match *self {
253            RcptParameter::Other {
254                ref keyword,
255                value: Some(ref value),
256            } => write!(f, "{}={}", keyword, XText(value)),
257            RcptParameter::Other {
258                ref keyword,
259                value: None,
260            } => f.write_str(keyword),
261        }
262    }
263}
264
265#[cfg(test)]
266mod test {
267
268    use super::{ClientId, Extension, ServerInfo};
269    use crate::authentication::Mechanism;
270    use crate::response::{Category, Code, Detail, Response, Severity};
271    use std::collections::HashSet;
272
273    #[test]
274    fn test_clientid_fmt() {
275        assert_eq!(
276            format!("{}", ClientId::new("test".to_string())),
277            "test".to_string()
278        );
279    }
280
281    #[test]
282    fn test_extension_fmt() {
283        assert_eq!(
284            format!("{}", Extension::Pipelining),
285            "PIPELINING".to_string()
286        );
287        assert_eq!(
288            format!("{}", Extension::EightBitMime),
289            "8BITMIME".to_string()
290        );
291        assert_eq!(
292            format!("{}", Extension::Authentication(Mechanism::Plain)),
293            "AUTH PLAIN".to_string()
294        );
295    }
296
297    #[test]
298    fn test_serverinfo_fmt() {
299        let mut eightbitmime = HashSet::new();
300        assert!(eightbitmime.insert(Extension::EightBitMime));
301
302        assert_eq!(
303            format!(
304                "{}",
305                ServerInfo {
306                    name: "name".to_string(),
307                    features: eightbitmime.clone(),
308                }
309            ),
310            "name with {EightBitMime}".to_string()
311        );
312
313        let empty = HashSet::new();
314
315        assert_eq!(
316            format!(
317                "{}",
318                ServerInfo {
319                    name: "name".to_string(),
320                    features: empty,
321                }
322            ),
323            "name with no supported features".to_string()
324        );
325
326        let mut plain = HashSet::new();
327        assert!(plain.insert(Extension::Authentication(Mechanism::Plain)));
328
329        assert_eq!(
330            format!(
331                "{}",
332                ServerInfo {
333                    name: "name".to_string(),
334                    features: plain.clone(),
335                }
336            ),
337            "name with {Authentication(Plain)}".to_string()
338        );
339    }
340
341    #[test]
342    fn test_serverinfo() {
343        let response = Response::new(
344            Code::new(
345                Severity::PositiveCompletion,
346                Category::Unspecified4,
347                Detail::One,
348            ),
349            vec![
350                "me".to_string(),
351                "8BITMIME".to_string(),
352                "SIZE 42".to_string(),
353            ],
354        );
355
356        let mut features = HashSet::new();
357        assert!(features.insert(Extension::EightBitMime));
358
359        let server_info = ServerInfo {
360            name: "me".to_string(),
361            features,
362        };
363
364        assert_eq!(ServerInfo::from_response(&response).unwrap(), server_info);
365
366        assert!(server_info.supports_feature(Extension::EightBitMime));
367        assert!(!server_info.supports_feature(Extension::StartTls));
368
369        let response2 = Response::new(
370            Code::new(
371                Severity::PositiveCompletion,
372                Category::Unspecified4,
373                Detail::One,
374            ),
375            vec![
376                "me".to_string(),
377                "AUTH PLAIN CRAM-MD5 XOAUTH2 OTHER".to_string(),
378                "8BITMIME".to_string(),
379                "SIZE 42".to_string(),
380            ],
381        );
382
383        let mut features2 = HashSet::new();
384        assert!(features2.insert(Extension::EightBitMime));
385        assert!(features2.insert(Extension::Authentication(Mechanism::Plain),));
386        assert!(features2.insert(Extension::Authentication(Mechanism::Xoauth2),));
387
388        let server_info2 = ServerInfo {
389            name: "me".to_string(),
390            features: features2,
391        };
392
393        assert_eq!(ServerInfo::from_response(&response2).unwrap(), server_info2);
394
395        assert!(server_info2.supports_feature(Extension::EightBitMime));
396        assert!(server_info2.supports_auth_mechanism(Mechanism::Plain));
397        assert!(!server_info2.supports_feature(Extension::StartTls));
398    }
399}