rustlavel_mail/smtp/
reply.rs1use rustlavel_core::{Error, Result};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Reply {
13 pub code: u16,
14 pub lines: Vec<String>,
16}
17
18impl Reply {
19 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 pub fn text(&self) -> String {
76 self.lines.join(" ")
77 }
78
79 pub fn is_positive(&self) -> bool {
81 (200..300).contains(&self.code)
82 }
83
84 pub fn is_intermediate(&self) -> bool {
86 (300..400).contains(&self.code)
87 }
88
89 pub fn is_transient(&self) -> bool {
91 (400..500).contains(&self.code)
92 }
93}
94
95pub 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 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 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 assert_eq!(complete_reply_end(b"250 one\r\n250 two\r\n"), Some(9));
191 }
192}