lettre/transport/smtp/
extension.rs

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