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::XClient(_) => false,
289                };
290            }
291        }
292
293        true
294    }
295}
296
297#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Hash)]
298pub struct EnhancedStatusCode {
299    pub class: u8,
300    pub subject: u16,
301    pub detail: u16,
302}
303
304fn parse_enhanced_status_code(line: &str) -> Option<(EnhancedStatusCode, &str)> {
305    let mut fields = line.splitn(3, '.');
306    let class = fields.next()?.parse::<u8>().ok()?;
307    if !matches!(class, 2 | 4 | 5) {
308        // No other classes are defined
309        return None;
310    }
311    let subject = fields.next()?.parse::<u16>().ok()?;
312
313    let remainder = fields.next()?;
314    let mut fields = remainder.splitn(2, ' ');
315    let detail = fields.next()?.parse::<u16>().ok()?;
316    let remainder = fields.next()?;
317
318    Some((
319        EnhancedStatusCode {
320            class,
321            subject,
322            detail,
323        },
324        remainder,
325    ))
326}
327
328fn remove_line_break(data: &str) -> String {
329    let data = data.as_bytes();
330    let mut normalized = Vec::with_capacity(data.len());
331    let mut last_idx = 0;
332
333    for i in memchr::memchr2_iter(b'\r', b'\n', data) {
334        match data[i] {
335            b'\r' => {
336                normalized.extend_from_slice(&data[last_idx..i]);
337                if data.get(i + 1).copied() != Some(b'\n') {
338                    normalized.push(b' ');
339                }
340            }
341            b'\n' => {
342                normalized.extend_from_slice(&data[last_idx..i]);
343                normalized.push(b' ');
344            }
345            _ => unreachable!(),
346        }
347        last_idx = i + 1;
348    }
349
350    normalized.extend_from_slice(&data[last_idx..]);
351    // This is safe because data comes from str, which is
352    // guaranteed to be valid utf8, and all we're manipulating
353    // above is whitespace which won't invalidate the utf8
354    // byte sequences in the data byte array
355    unsafe { String::from_utf8_unchecked(normalized) }
356}
357
358#[derive(Debug, PartialEq, Eq)]
359pub(crate) struct ResponseLine<'a> {
360    pub code: u16,
361    pub is_final: bool,
362    pub content: &'a str,
363}
364
365impl ResponseLine<'_> {
366    /// Reconsitute the original line that we parsed
367    fn to_original_line(&self) -> String {
368        format!(
369            "{}{}{}",
370            self.code,
371            if self.is_final { " " } else { "-" },
372            self.content
373        )
374    }
375}
376
377pub(crate) struct ResponseBuilder {
378    pub code: u16,
379    pub enhanced_code: Option<EnhancedStatusCode>,
380    pub content: String,
381}
382
383impl ResponseBuilder {
384    pub fn new(parsed: &ResponseLine) -> Self {
385        let code = parsed.code;
386        let (enhanced_code, content) = match parse_enhanced_status_code(parsed.content) {
387            Some((enhanced, content)) => (Some(enhanced), content.to_string()),
388            None => (None, parsed.content.to_string()),
389        };
390
391        Self {
392            code,
393            enhanced_code,
394            content,
395        }
396    }
397
398    pub fn add_line(&mut self, parsed: &ResponseLine) -> Result<(), String> {
399        if parsed.code != self.code {
400            return Err(parsed.to_original_line());
401        }
402
403        self.content.push('\n');
404
405        let mut content = parsed.content;
406
407        if let Some(enh) = &self.enhanced_code {
408            let prefix = format!("{}.{}.{} ", enh.class, enh.subject, enh.detail);
409            if let Some(remainder) = parsed.content.strip_prefix(&prefix) {
410                content = remainder;
411            }
412        }
413
414        self.content.push_str(content);
415        Ok(())
416    }
417
418    pub fn build(self, command: Option<String>) -> Response {
419        Response {
420            code: self.code,
421            content: self.content,
422            enhanced_code: self.enhanced_code,
423            command,
424        }
425    }
426}
427
428#[allow(clippy::ptr_arg)]
429fn as_single_line<S>(content: &String, serializer: S) -> Result<S::Ok, S::Error>
430where
431    S: serde::Serializer,
432{
433    serializer.serialize_str(&remove_line_break(content))
434}
435
436#[cfg(test)]
437mod test {
438    use super::*;
439
440    #[test]
441    fn remove_crlf() {
442        fn remove(s: &str, expect: &str) {
443            assert_eq!(remove_line_break(s), expect, "input: {s:?}");
444        }
445
446        remove("hello\r\nthere\r\n", "hello there ");
447        remove("hello\r", "hello ");
448        remove("hello\nthere\r\n", "hello there ");
449        remove("hello\r\nthere\n", "hello there ");
450        remove("hello\r\r\r\nthere\n", "hello   there ");
451    }
452
453    #[test]
454    fn response_parsing() {
455        assert_eq!(
456            parse_enhanced_status_code("2.0.1 w00t"),
457            Some((
458                EnhancedStatusCode {
459                    class: 2,
460                    subject: 0,
461                    detail: 1
462                },
463                "w00t"
464            ))
465        );
466
467        assert_eq!(parse_enhanced_status_code("3.0.0 w00t"), None);
468
469        assert_eq!(parse_enhanced_status_code("2.0.0.1 w00t"), None);
470
471        assert_eq!(parse_enhanced_status_code("2.0.0.1w00t"), None);
472    }
473}