Skip to main content

rustlavel_mail/smtp/
reply.rs

1//! Parsing SMTP replies.
2//!
3//! A reply is a three-digit code and some text, except when it is several
4//! lines, each repeating the code, with `-` after the code on every line but
5//! the last. `EHLO` always answers that way, so this is the first thing an
6//! SMTP client has to get right.
7
8use rustlavel_core::{Error, Result};
9
10/// One complete reply from the server.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Reply {
13    pub code: u16,
14    /// The text of each line, without the code and the separator.
15    pub lines: Vec<String>,
16}
17
18impl Reply {
19    /// Parse a complete reply block, one or many lines.
20    pub fn parse(raw: &str) -> Result<Reply> {
21        let mut code: Option<u16> = None;
22        let mut lines = Vec::new();
23        let mut finished = false;
24
25        for line in raw.replace("\r\n", "\n").split('\n') {
26            if line.is_empty() {
27                continue;
28            }
29            if finished {
30                return Err(Error::Protocol(format!(
31                    "the server sent `{line}` after its reply had already ended"
32                )));
33            }
34
35            let bytes = line.as_bytes();
36            if bytes.len() < 3 || !bytes[..3].iter().all(u8::is_ascii_digit) {
37                return Err(Error::Protocol(format!(
38                    "`{line}` is not an SMTP reply — a reply starts with a three-digit code"
39                )));
40            }
41            let this_code: u16 = line[..3].parse().expect("three ASCII digits");
42
43            match code {
44                None => code = Some(this_code),
45                Some(first) if first != this_code => {
46                    return Err(Error::Protocol(format!(
47                        "the server changed reply code from {first} to {this_code} mid-reply"
48                    )));
49                }
50                Some(_) => {}
51            }
52
53            match bytes.get(3) {
54                None | Some(b' ') => finished = true,
55                Some(b'-') => {}
56                Some(other) => {
57                    return Err(Error::Protocol(format!(
58                        "expected ` ` or `-` after the reply code, got `{}`",
59                        *other as char
60                    )));
61                }
62            }
63
64            lines.push(line.get(4..).unwrap_or("").trim_end().to_string());
65        }
66
67        match code {
68            Some(code) if finished => Ok(Reply { code, lines }),
69            Some(_) => Err(Error::Protocol("the reply ended before its last line".into())),
70            None => Err(Error::Protocol("the server sent an empty reply".into())),
71        }
72    }
73
74    /// The reply text, joined — this is what goes into an error message.
75    pub fn text(&self) -> String {
76        self.lines.join(" ")
77    }
78
79    /// 2xx: it worked.
80    pub fn is_positive(&self) -> bool {
81        (200..300).contains(&self.code)
82    }
83
84    /// 3xx: the server wants more input before it will answer properly.
85    pub fn is_intermediate(&self) -> bool {
86        (300..400).contains(&self.code)
87    }
88
89    /// 4xx: try again later. 5xx means never.
90    pub fn is_transient(&self) -> bool {
91        (400..500).contains(&self.code)
92    }
93}
94
95/// Where a complete reply ends in `buffer`, if one has arrived yet.
96///
97/// The terminator is a line whose code is followed by a space rather than a
98/// dash; until then the server is still talking.
99pub fn complete_reply_end(buffer: &[u8]) -> Option<usize> {
100    let mut start = 0;
101
102    while let Some(offset) = buffer[start..].iter().position(|byte| *byte == b'\n') {
103        let end = start + offset + 1;
104        let line = &buffer[start..end];
105        let trimmed: &[u8] = line.strip_suffix(b"\r\n").or_else(|| line.strip_suffix(b"\n")).unwrap_or(line);
106
107        // A short or malformed line is left for the parser to complain about,
108        // rather than being read as "keep waiting" and hanging the connection.
109        let terminal = trimmed.len() < 4 || trimmed[3] == b' ';
110        if terminal {
111            return Some(end);
112        }
113        start = end;
114    }
115
116    None
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn a_single_line_reply_parses() {
125        let reply = Reply::parse("250 OK\r\n").unwrap();
126
127        assert_eq!(reply.code, 250);
128        assert_eq!(reply.lines, vec!["OK"]);
129        assert!(reply.is_positive());
130    }
131
132    #[test]
133    fn a_multi_line_ehlo_reply_keeps_every_capability() {
134        let raw = "250-smtp.example.com at your service\r\n\
135                   250-SIZE 35882577\r\n\
136                   250-STARTTLS\r\n\
137                   250-AUTH LOGIN PLAIN XOAUTH2\r\n\
138                   250 8BITMIME\r\n";
139
140        let reply = Reply::parse(raw).unwrap();
141
142        assert_eq!(reply.code, 250);
143        assert_eq!(
144            reply.lines,
145            vec![
146                "smtp.example.com at your service",
147                "SIZE 35882577",
148                "STARTTLS",
149                "AUTH LOGIN PLAIN XOAUTH2",
150                "8BITMIME",
151            ]
152        );
153    }
154
155    #[test]
156    fn a_reply_code_with_no_text_is_still_a_reply() {
157        let reply = Reply::parse("250\r\n").unwrap();
158        assert_eq!((reply.code, reply.lines.as_slice()), (250, ["".to_string()].as_slice()));
159    }
160
161    #[test]
162    fn a_reply_that_changes_its_code_is_rejected() {
163        let error = Reply::parse("250-first\r\n451 second\r\n").unwrap_err().to_string();
164        assert!(error.contains("changed reply code"), "{error}");
165    }
166
167    #[test]
168    fn something_that_is_not_a_reply_is_rejected() {
169        assert!(Reply::parse("hello?\r\n").is_err());
170        assert!(Reply::parse("25 short\r\n").is_err());
171        assert!(Reply::parse("").is_err());
172        // A block that only ever continues never ends.
173        assert!(Reply::parse("250-one\r\n250-two\r\n").is_err());
174    }
175
176    #[test]
177    fn reply_classes_read_the_first_digit() {
178        assert!(Reply::parse("354 Go ahead").unwrap().is_intermediate());
179        assert!(Reply::parse("451 Try later").unwrap().is_transient());
180        assert!(!Reply::parse("550 Nope").unwrap().is_transient());
181    }
182
183    #[test]
184    fn a_reply_is_complete_only_after_its_terminating_line() {
185        assert_eq!(complete_reply_end(b"250 OK\r\n"), Some(8));
186        assert_eq!(complete_reply_end(b"250 OK\r"), None);
187        assert_eq!(complete_reply_end(b"250-one\r\n"), None);
188        assert_eq!(complete_reply_end(b"250-one\r\n250 two\r\n"), Some(18));
189        // Bytes belonging to the next reply are left in the buffer.
190        assert_eq!(complete_reply_end(b"250 one\r\n250 two\r\n"), Some(9));
191    }
192}