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, Copy, Hash)]
165pub enum IsTooManyRecipients {
166    Yes,
167    No,
168    Maybe,
169}
170
171#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Hash)]
172pub struct Response {
173    pub code: u16,
174    pub enhanced_code: Option<EnhancedStatusCode>,
175    #[serde(serialize_with = "as_single_line")]
176    pub content: String,
177    pub command: Option<String>,
178}
179
180impl Response {
181    pub fn to_single_line(&self) -> String {
182        let mut line = format!("{} ", self.code);
183
184        if let Some(enh) = &self.enhanced_code {
185            line.push_str(&format!("{}.{}.{} ", enh.class, enh.subject, enh.detail));
186        }
187
188        line.push_str(&remove_line_break(&self.content));
189
190        line
191    }
192
193    pub fn is_transient(&self) -> bool {
194        self.code >= 400 && self.code < 500
195    }
196
197    pub fn is_permanent(&self) -> bool {
198        self.code >= 500 && self.code < 600
199    }
200
201    pub fn is_too_many_recipients(&self) -> IsTooManyRecipients {
202        // RFC 5321 4.5.3.1.10: RFC 821 incorrectly ... "too many recipients" ..
203        // as having reply code 552 ... the correct reply ... is 452.
204        // Clients should treat 552 ... as a temporary (for RCPT TO)
205        if !self
206            .command
207            .as_deref()
208            .map(|c| c.starts_with("RCPT"))
209            .unwrap_or(false)
210        {
211            return IsTooManyRecipients::No;
212        }
213
214        match (self.code, &self.enhanced_code) {
215            (452 | 552, None) => IsTooManyRecipients::Maybe,
216            (
217                _,
218                // <https://www.iana.org/assignments/smtp-enhanced-status-codes/smtp-enhanced-status-codes.xhtml>
219                // indicates that a 451 4.5.3 means "too many recipients".
220                // Interestingly, that conflicts with RFC 5321 which says
221                // that 451 means "local error in processing". It's likely a typo
222                // and 452 was intended to be used there.
223                // When matching the enhanced status code, we'll ignore the
224                // base SMTP response code for the purposes of this check:
225                // we're already scoped to RCPT TO so this feels reasonable.
226                Some(EnhancedStatusCode {
227                    class: 4,
228                    subject: 5,
229                    detail: 3,
230                }),
231            ) => IsTooManyRecipients::Yes,
232            _ => IsTooManyRecipients::No,
233        }
234    }
235
236    pub fn with_code_and_message(code: u16, message: &str) -> Self {
237        let lines: Vec<&str> = message.lines().collect();
238
239        let mut builder = ResponseBuilder::new(&ResponseLine {
240            code,
241            content: lines[0],
242            is_final: lines.len() == 1,
243        });
244
245        for (n, line) in lines.iter().enumerate().skip(1) {
246            builder
247                .add_line(&ResponseLine {
248                    code,
249                    content: line,
250                    is_final: n == lines.len() - 1,
251                })
252                .ok();
253        }
254
255        builder.build(None)
256    }
257
258    /// If the error contents were likely caused by something
259    /// about the mostly recently attempted message, rather than
260    /// a transport issue, or a carry-over from a prior message
261    /// (eg: previous message was rejected and destination chose
262    /// to drop the connection, which we detect later in RSET
263    /// on the next message), then we return true.
264    /// The expectation is that the caller will transiently
265    /// fail the message for later retry.
266    /// If we return false then the caller might decide to
267    /// try that same message again more immediately on
268    /// a separate connection
269    pub fn was_due_to_message(&self) -> bool {
270        if let Some(command) = &self.command {
271            if let Ok(cmd) = Command::parse(command) {
272                return match cmd {
273                    Command::MailFrom { .. }
274                    | Command::RcptTo { .. }
275                    | Command::Data
276                    | Command::DataDot => true,
277                    Command::Ehlo(_)
278                    | Command::Helo(_)
279                    | Command::Lhlo(_)
280                    | Command::Rset
281                    | Command::Quit
282                    | Command::StartTls
283                    | Command::Vrfy(_)
284                    | Command::Expn(_)
285                    | Command::Noop(_)
286                    | Command::Help(_)
287                    | Command::Auth { .. }
288                    | Command::RawLine(_)
289                    | Command::XClient(_) => false,
290                };
291            }
292        }
293
294        true
295    }
296}
297
298#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Hash)]
299pub struct EnhancedStatusCode {
300    pub class: u8,
301    pub subject: u16,
302    pub detail: u16,
303}
304
305fn parse_enhanced_status_code(line: &str) -> Option<(EnhancedStatusCode, &str)> {
306    let mut fields = line.splitn(3, '.');
307    let class = fields.next()?.parse::<u8>().ok()?;
308    if !matches!(class, 2 | 4 | 5) {
309        // No other classes are defined
310        return None;
311    }
312    let subject = fields.next()?.parse::<u16>().ok()?;
313
314    let remainder = fields.next()?;
315    let mut fields = remainder.splitn(2, ' ');
316    let detail = fields.next()?.parse::<u16>().ok()?;
317    let remainder = fields.next()?;
318
319    Some((
320        EnhancedStatusCode {
321            class,
322            subject,
323            detail,
324        },
325        remainder,
326    ))
327}
328
329fn remove_line_break(data: &str) -> String {
330    let data = data.as_bytes();
331    let mut normalized = Vec::with_capacity(data.len());
332    let mut last_idx = 0;
333
334    for i in memchr::memchr2_iter(b'\r', b'\n', data) {
335        match data[i] {
336            b'\r' => {
337                normalized.extend_from_slice(&data[last_idx..i]);
338                if data.get(i + 1).copied() != Some(b'\n') {
339                    normalized.push(b' ');
340                }
341            }
342            b'\n' => {
343                normalized.extend_from_slice(&data[last_idx..i]);
344                normalized.push(b' ');
345            }
346            _ => unreachable!(),
347        }
348        last_idx = i + 1;
349    }
350
351    normalized.extend_from_slice(&data[last_idx..]);
352    // This is safe because data comes from str, which is
353    // guaranteed to be valid utf8, and all we're manipulating
354    // above is whitespace which won't invalidate the utf8
355    // byte sequences in the data byte array
356    unsafe { String::from_utf8_unchecked(normalized) }
357}
358
359#[derive(Debug, PartialEq, Eq)]
360pub(crate) struct ResponseLine<'a> {
361    pub code: u16,
362    pub is_final: bool,
363    pub content: &'a str,
364}
365
366impl ResponseLine<'_> {
367    /// Reconsitute the original line that we parsed
368    fn to_original_line(&self) -> String {
369        format!(
370            "{}{}{}",
371            self.code,
372            if self.is_final { " " } else { "-" },
373            self.content
374        )
375    }
376}
377
378pub(crate) struct ResponseBuilder {
379    pub code: u16,
380    pub enhanced_code: Option<EnhancedStatusCode>,
381    pub content: String,
382}
383
384impl ResponseBuilder {
385    pub fn new(parsed: &ResponseLine) -> Self {
386        let code = parsed.code;
387        let (enhanced_code, content) = match parse_enhanced_status_code(parsed.content) {
388            Some((enhanced, content)) => (Some(enhanced), content.to_string()),
389            None => (None, parsed.content.to_string()),
390        };
391
392        Self {
393            code,
394            enhanced_code,
395            content,
396        }
397    }
398
399    pub fn add_line(&mut self, parsed: &ResponseLine) -> Result<(), String> {
400        if parsed.code != self.code {
401            return Err(parsed.to_original_line());
402        }
403
404        self.content.push('\n');
405
406        let mut content = parsed.content;
407
408        if let Some(enh) = &self.enhanced_code {
409            let prefix = format!("{}.{}.{} ", enh.class, enh.subject, enh.detail);
410            if let Some(remainder) = parsed.content.strip_prefix(&prefix) {
411                content = remainder;
412            }
413        }
414
415        self.content.push_str(content);
416        Ok(())
417    }
418
419    pub fn build(self, command: Option<String>) -> Response {
420        Response {
421            code: self.code,
422            content: self.content,
423            enhanced_code: self.enhanced_code,
424            command,
425        }
426    }
427}
428
429#[allow(clippy::ptr_arg)]
430fn as_single_line<S>(content: &String, serializer: S) -> Result<S::Ok, S::Error>
431where
432    S: serde::Serializer,
433{
434    serializer.serialize_str(&remove_line_break(content))
435}
436
437#[cfg(test)]
438mod test {
439    use super::*;
440
441    #[test]
442    fn remove_crlf() {
443        fn remove(s: &str, expect: &str) {
444            assert_eq!(remove_line_break(s), expect, "input: {s:?}");
445        }
446
447        remove("hello\r\nthere\r\n", "hello there ");
448        remove("hello\r", "hello ");
449        remove("hello\nthere\r\n", "hello there ");
450        remove("hello\r\nthere\n", "hello there ");
451        remove("hello\r\r\r\nthere\n", "hello   there ");
452    }
453
454    #[test]
455    fn response_parsing() {
456        assert_eq!(
457            parse_enhanced_status_code("2.0.1 w00t"),
458            Some((
459                EnhancedStatusCode {
460                    class: 2,
461                    subject: 0,
462                    detail: 1
463                },
464                "w00t"
465            ))
466        );
467
468        assert_eq!(parse_enhanced_status_code("3.0.0 w00t"), None);
469
470        assert_eq!(parse_enhanced_status_code("2.0.0.1 w00t"), None);
471
472        assert_eq!(parse_enhanced_status_code("2.0.0.1w00t"), None);
473    }
474}