rfc5321/
client_types.rs

1use crate::Command;
2use serde::{Deserialize, Serialize};
3use std::time::Duration;
4
5#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq)]
6pub struct SmtpClientTimeouts {
7    #[serde(
8        default = "SmtpClientTimeouts::default_connect_timeout",
9        with = "duration_serde"
10    )]
11    pub connect_timeout: Duration,
12
13    #[serde(
14        default = "SmtpClientTimeouts::default_banner_timeout",
15        with = "duration_serde"
16    )]
17    pub banner_timeout: Duration,
18
19    #[serde(
20        default = "SmtpClientTimeouts::default_ehlo_timeout",
21        with = "duration_serde"
22    )]
23    pub ehlo_timeout: Duration,
24
25    #[serde(
26        default = "SmtpClientTimeouts::default_mail_from_timeout",
27        with = "duration_serde"
28    )]
29    pub mail_from_timeout: Duration,
30
31    #[serde(
32        default = "SmtpClientTimeouts::default_rcpt_to_timeout",
33        with = "duration_serde"
34    )]
35    pub rcpt_to_timeout: Duration,
36
37    #[serde(
38        default = "SmtpClientTimeouts::default_data_timeout",
39        with = "duration_serde"
40    )]
41    pub data_timeout: Duration,
42    #[serde(
43        default = "SmtpClientTimeouts::default_data_dot_timeout",
44        with = "duration_serde"
45    )]
46    pub data_dot_timeout: Duration,
47    #[serde(
48        default = "SmtpClientTimeouts::default_rset_timeout",
49        with = "duration_serde"
50    )]
51    pub rset_timeout: Duration,
52
53    #[serde(
54        default = "SmtpClientTimeouts::default_idle_timeout",
55        with = "duration_serde"
56    )]
57    pub idle_timeout: Duration,
58
59    #[serde(
60        default = "SmtpClientTimeouts::default_starttls_timeout",
61        with = "duration_serde"
62    )]
63    pub starttls_timeout: Duration,
64
65    #[serde(
66        default = "SmtpClientTimeouts::default_auth_timeout",
67        with = "duration_serde"
68    )]
69    pub auth_timeout: Duration,
70}
71
72impl Default for SmtpClientTimeouts {
73    fn default() -> Self {
74        Self {
75            connect_timeout: Self::default_connect_timeout(),
76            banner_timeout: Self::default_banner_timeout(),
77            ehlo_timeout: Self::default_ehlo_timeout(),
78            mail_from_timeout: Self::default_mail_from_timeout(),
79            rcpt_to_timeout: Self::default_rcpt_to_timeout(),
80            data_timeout: Self::default_data_timeout(),
81            data_dot_timeout: Self::default_data_dot_timeout(),
82            rset_timeout: Self::default_rset_timeout(),
83            idle_timeout: Self::default_idle_timeout(),
84            starttls_timeout: Self::default_starttls_timeout(),
85            auth_timeout: Self::default_auth_timeout(),
86        }
87    }
88}
89
90impl SmtpClientTimeouts {
91    fn default_connect_timeout() -> Duration {
92        Duration::from_secs(60)
93    }
94    fn default_banner_timeout() -> Duration {
95        Duration::from_secs(60)
96    }
97    fn default_auth_timeout() -> Duration {
98        Duration::from_secs(60)
99    }
100    fn default_ehlo_timeout() -> Duration {
101        Duration::from_secs(300)
102    }
103    fn default_mail_from_timeout() -> Duration {
104        Duration::from_secs(300)
105    }
106    fn default_rcpt_to_timeout() -> Duration {
107        Duration::from_secs(300)
108    }
109    fn default_data_timeout() -> Duration {
110        Duration::from_secs(300)
111    }
112    fn default_data_dot_timeout() -> Duration {
113        Duration::from_secs(300)
114    }
115    fn default_rset_timeout() -> Duration {
116        Duration::from_secs(5)
117    }
118    fn default_idle_timeout() -> Duration {
119        Duration::from_secs(5)
120    }
121    fn default_starttls_timeout() -> Duration {
122        Duration::from_secs(5)
123    }
124
125    pub fn short_timeouts() -> Self {
126        let short = Duration::from_secs(20);
127        Self {
128            connect_timeout: short,
129            banner_timeout: short,
130            ehlo_timeout: short,
131            mail_from_timeout: short,
132            rcpt_to_timeout: short,
133            data_timeout: short,
134            data_dot_timeout: short,
135            rset_timeout: short,
136            idle_timeout: short,
137            starttls_timeout: short,
138            auth_timeout: short,
139        }
140    }
141
142    /// Compute theoretical maximum lifetime of a connection when
143    /// sending a single message
144    pub fn total_connection_lifetime_duration(&self) -> Duration {
145        self.connect_timeout
146            + self.banner_timeout
147            + self.ehlo_timeout
148            + self.auth_timeout
149            + self.mail_from_timeout
150            + self.rcpt_to_timeout
151            + self.data_timeout
152            + self.data_dot_timeout
153            + self.starttls_timeout
154            + self.idle_timeout
155    }
156
157    /// Compute theoretical maximum lifetime of a single message send
158    /// on an already established connection
159    pub fn total_message_send_duration(&self) -> Duration {
160        self.mail_from_timeout + self.rcpt_to_timeout + self.data_timeout + self.data_dot_timeout
161    }
162}
163
164#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Hash)]
165pub struct Response {
166    pub code: u16,
167    pub enhanced_code: Option<EnhancedStatusCode>,
168    #[serde(serialize_with = "as_single_line")]
169    pub content: String,
170    pub command: Option<String>,
171}
172
173impl Response {
174    pub fn to_single_line(&self) -> String {
175        let mut line = format!("{} ", self.code);
176
177        if let Some(enh) = &self.enhanced_code {
178            line.push_str(&format!("{}.{}.{} ", enh.class, enh.subject, enh.detail));
179        }
180
181        line.push_str(&remove_line_break(&self.content));
182
183        line
184    }
185
186    pub fn is_transient(&self) -> bool {
187        self.code >= 400 && self.code < 500
188    }
189
190    pub fn is_permanent(&self) -> bool {
191        self.code >= 500 && self.code < 600
192    }
193
194    pub fn with_code_and_message(code: u16, message: &str) -> Self {
195        let lines: Vec<&str> = message.lines().collect();
196
197        let mut builder = ResponseBuilder::new(&ResponseLine {
198            code,
199            content: lines[0],
200            is_final: lines.len() == 1,
201        });
202
203        for (n, line) in lines.iter().enumerate().skip(1) {
204            builder
205                .add_line(&ResponseLine {
206                    code,
207                    content: line,
208                    is_final: n == lines.len() - 1,
209                })
210                .ok();
211        }
212
213        builder.build(None)
214    }
215
216    /// If the error contents were likely caused by something
217    /// about the mostly recently attempted message, rather than
218    /// a transport issue, or a carry-over from a prior message
219    /// (eg: previous message was rejected and destination chose
220    /// to drop the connection, which we detect later in RSET
221    /// on the next message), then we return true.
222    /// The expectation is that the caller will transiently
223    /// fail the message for later retry.
224    /// If we return false then the caller might decide to
225    /// try that same message again more immediately on
226    /// a separate connection
227    pub fn was_due_to_message(&self) -> bool {
228        if let Some(command) = &self.command {
229            if let Ok(cmd) = Command::parse(command) {
230                return match cmd {
231                    Command::MailFrom { .. }
232                    | Command::RcptTo { .. }
233                    | Command::Data
234                    | Command::DataDot => true,
235                    Command::Ehlo(_)
236                    | Command::Helo(_)
237                    | Command::Lhlo(_)
238                    | Command::Rset
239                    | Command::Quit
240                    | Command::StartTls
241                    | Command::Vrfy(_)
242                    | Command::Expn(_)
243                    | Command::Noop(_)
244                    | Command::Help(_)
245                    | Command::Auth { .. } => false,
246                };
247            }
248        }
249
250        true
251    }
252}
253
254#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Hash)]
255pub struct EnhancedStatusCode {
256    pub class: u8,
257    pub subject: u16,
258    pub detail: u16,
259}
260
261fn parse_enhanced_status_code(line: &str) -> Option<(EnhancedStatusCode, &str)> {
262    let mut fields = line.splitn(3, '.');
263    let class = fields.next()?.parse::<u8>().ok()?;
264    if !matches!(class, 2 | 4 | 5) {
265        // No other classes are defined
266        return None;
267    }
268    let subject = fields.next()?.parse::<u16>().ok()?;
269
270    let remainder = fields.next()?;
271    let mut fields = remainder.splitn(2, ' ');
272    let detail = fields.next()?.parse::<u16>().ok()?;
273    let remainder = fields.next()?;
274
275    Some((
276        EnhancedStatusCode {
277            class,
278            subject,
279            detail,
280        },
281        remainder,
282    ))
283}
284
285fn remove_line_break(data: &str) -> String {
286    let data = data.as_bytes();
287    let mut normalized = Vec::with_capacity(data.len());
288    let mut last_idx = 0;
289
290    for i in memchr::memchr2_iter(b'\r', b'\n', data) {
291        match data[i] {
292            b'\r' => {
293                normalized.extend_from_slice(&data[last_idx..i]);
294                if data.get(i + 1).copied() != Some(b'\n') {
295                    normalized.push(b' ');
296                }
297            }
298            b'\n' => {
299                normalized.extend_from_slice(&data[last_idx..i]);
300                normalized.push(b' ');
301            }
302            _ => unreachable!(),
303        }
304        last_idx = i + 1;
305    }
306
307    normalized.extend_from_slice(&data[last_idx..]);
308    // This is safe because data comes from str, which is
309    // guaranteed to be valid utf8, and all we're manipulating
310    // above is whitespace which won't invalidate the utf8
311    // byte sequences in the data byte array
312    unsafe { String::from_utf8_unchecked(normalized) }
313}
314
315#[derive(Debug, PartialEq, Eq)]
316pub(crate) struct ResponseLine<'a> {
317    pub code: u16,
318    pub is_final: bool,
319    pub content: &'a str,
320}
321
322impl ResponseLine<'_> {
323    /// Reconsitute the original line that we parsed
324    fn to_original_line(&self) -> String {
325        format!(
326            "{}{}{}",
327            self.code,
328            if self.is_final { " " } else { "-" },
329            self.content
330        )
331    }
332}
333
334pub(crate) struct ResponseBuilder {
335    pub code: u16,
336    pub enhanced_code: Option<EnhancedStatusCode>,
337    pub content: String,
338}
339
340impl ResponseBuilder {
341    pub fn new(parsed: &ResponseLine) -> Self {
342        let code = parsed.code;
343        let (enhanced_code, content) = match parse_enhanced_status_code(parsed.content) {
344            Some((enhanced, content)) => (Some(enhanced), content.to_string()),
345            None => (None, parsed.content.to_string()),
346        };
347
348        Self {
349            code,
350            enhanced_code,
351            content,
352        }
353    }
354
355    pub fn add_line(&mut self, parsed: &ResponseLine) -> Result<(), String> {
356        if parsed.code != self.code {
357            return Err(parsed.to_original_line());
358        }
359
360        self.content.push('\n');
361
362        let mut content = parsed.content;
363
364        if let Some(enh) = &self.enhanced_code {
365            let prefix = format!("{}.{}.{} ", enh.class, enh.subject, enh.detail);
366            if let Some(remainder) = parsed.content.strip_prefix(&prefix) {
367                content = remainder;
368            }
369        }
370
371        self.content.push_str(content);
372        Ok(())
373    }
374
375    pub fn build(self, command: Option<String>) -> Response {
376        Response {
377            code: self.code,
378            content: self.content,
379            enhanced_code: self.enhanced_code,
380            command,
381        }
382    }
383}
384
385#[allow(clippy::ptr_arg)]
386fn as_single_line<S>(content: &String, serializer: S) -> Result<S::Ok, S::Error>
387where
388    S: serde::Serializer,
389{
390    serializer.serialize_str(&remove_line_break(content))
391}
392
393#[cfg(test)]
394mod test {
395    use super::*;
396
397    #[test]
398    fn remove_crlf() {
399        fn remove(s: &str, expect: &str) {
400            assert_eq!(remove_line_break(s), expect, "input: {s:?}");
401        }
402
403        remove("hello\r\nthere\r\n", "hello there ");
404        remove("hello\r", "hello ");
405        remove("hello\nthere\r\n", "hello there ");
406        remove("hello\r\nthere\n", "hello there ");
407        remove("hello\r\r\r\nthere\n", "hello   there ");
408    }
409
410    #[test]
411    fn response_parsing() {
412        assert_eq!(
413            parse_enhanced_status_code("2.0.1 w00t"),
414            Some((
415                EnhancedStatusCode {
416                    class: 2,
417                    subject: 0,
418                    detail: 1
419                },
420                "w00t"
421            ))
422        );
423
424        assert_eq!(parse_enhanced_status_code("3.0.0 w00t"), None);
425
426        assert_eq!(parse_enhanced_status_code("2.0.0.1 w00t"), None);
427
428        assert_eq!(parse_enhanced_status_code("2.0.0.1w00t"), None);
429    }
430}