stackforge_core/layer/smtp/
builder.rs1#[must_use]
21#[derive(Debug, Clone)]
22pub struct SmtpBuilder {
23 reply_code: Option<u16>,
24 command: Option<String>,
25 text: String,
26 multiline: bool,
27 extra_lines: Vec<String>,
28}
29
30impl Default for SmtpBuilder {
31 fn default() -> Self {
32 Self {
33 reply_code: None,
34 command: Some("NOOP".to_string()),
35 text: String::new(),
36 multiline: false,
37 extra_lines: Vec::new(),
38 }
39 }
40}
41
42impl SmtpBuilder {
43 pub fn new() -> Self {
44 Self::default()
45 }
46
47 pub fn reply(mut self, code: u16, text: impl Into<String>) -> Self {
52 self.reply_code = Some(code);
53 self.command = None;
54 self.text = text.into();
55 self
56 }
57
58 pub fn multiline(mut self, lines: Vec<String>) -> Self {
59 self.multiline = true;
60 self.extra_lines = lines;
61 self
62 }
63
64 pub fn service_ready(self, domain: impl Into<String>) -> Self {
66 let text = format!("{} ESMTP", domain.into());
67 self.reply(220, text)
68 }
69
70 pub fn closing(self) -> Self {
72 self.reply(221, "Bye")
73 }
74
75 pub fn auth_success(self) -> Self {
77 self.reply(235, "Authentication successful")
78 }
79
80 pub fn ok(self, text: impl Into<String>) -> Self {
82 self.reply(250, text)
83 }
84
85 pub fn ehlo_response(mut self, domain: impl Into<String>, extensions: Vec<String>) -> Self {
87 self.reply_code = Some(250);
88 self.command = None;
89 self.multiline = true;
90 self.text = domain.into();
91 self.extra_lines = extensions;
92 self
93 }
94
95 pub fn auth_challenge(self, challenge: impl Into<String>) -> Self {
97 self.reply(334, challenge)
98 }
99
100 pub fn start_mail_input(self) -> Self {
102 self.reply(354, "Start mail input; end with <CRLF>.<CRLF>")
103 }
104
105 pub fn service_unavailable(self) -> Self {
107 self.reply(421, "Service not available")
108 }
109
110 pub fn mailbox_temp_unavailable(self, text: impl Into<String>) -> Self {
112 self.reply(450, text)
113 }
114
115 pub fn auth_required(self) -> Self {
117 self.reply(530, "Authentication required")
118 }
119
120 pub fn auth_failed(self) -> Self {
122 self.reply(535, "Authentication credentials invalid")
123 }
124
125 pub fn mailbox_not_found(self, text: impl Into<String>) -> Self {
127 self.reply(550, text)
128 }
129
130 pub fn command(mut self, verb: impl Into<String>, args: impl Into<String>) -> Self {
135 self.command = Some(verb.into().to_ascii_uppercase());
136 self.reply_code = None;
137 self.text = args.into();
138 self
139 }
140
141 pub fn ehlo(self, domain: impl Into<String>) -> Self {
143 self.command("EHLO", domain)
144 }
145
146 pub fn helo(self, domain: impl Into<String>) -> Self {
148 self.command("HELO", domain)
149 }
150
151 pub fn mail_from(self, address: impl Into<String>) -> Self {
153 let addr = address.into();
154 let args = if addr.contains('<') {
155 format!("FROM:{addr}")
156 } else {
157 format!("FROM:<{addr}>")
158 };
159 self.command("MAIL", args)
160 }
161
162 pub fn rcpt_to(self, address: impl Into<String>) -> Self {
164 let addr = address.into();
165 let args = if addr.contains('<') {
166 format!("TO:{addr}")
167 } else {
168 format!("TO:<{addr}>")
169 };
170 self.command("RCPT", args)
171 }
172
173 pub fn data(self) -> Self {
175 self.command("DATA", "")
176 }
177
178 pub fn rset(self) -> Self {
180 self.command("RSET", "")
181 }
182
183 pub fn vrfy(self, address: impl Into<String>) -> Self {
185 self.command("VRFY", address)
186 }
187
188 pub fn expn(self, list: impl Into<String>) -> Self {
190 self.command("EXPN", list)
191 }
192
193 pub fn help(self, topic: impl Into<String>) -> Self {
195 self.command("HELP", topic)
196 }
197
198 pub fn noop(self) -> Self {
200 self.command("NOOP", "")
201 }
202
203 pub fn quit(self) -> Self {
205 self.command("QUIT", "")
206 }
207
208 pub fn auth(self, mechanism: impl Into<String>, initial_resp: impl Into<String>) -> Self {
210 let mech = mechanism.into();
211 let init = initial_resp.into();
212 let args = if init.is_empty() {
213 mech
214 } else {
215 format!("{mech} {init}")
216 };
217 self.command("AUTH", args)
218 }
219
220 pub fn starttls(self) -> Self {
222 self.command("STARTTLS", "")
223 }
224
225 #[must_use]
230 pub fn build(&self) -> Vec<u8> {
231 if let Some(code) = self.reply_code {
232 self.build_reply(code)
233 } else {
234 self.build_command()
235 }
236 }
237
238 fn build_reply(&self, code: u16) -> Vec<u8> {
239 let mut out = Vec::new();
240 if self.multiline {
241 out.extend_from_slice(format!("{:03}-{}\r\n", code, self.text).as_bytes());
242 for line in &self.extra_lines {
243 out.extend_from_slice(format!("{code:03}-{line}\r\n").as_bytes());
245 }
246 out.extend_from_slice(format!("{code:03} OK\r\n").as_bytes());
248 } else {
249 out.extend_from_slice(format!("{:03} {}\r\n", code, self.text).as_bytes());
250 }
251 out
252 }
253
254 fn build_command(&self) -> Vec<u8> {
255 let verb = self.command.as_deref().unwrap_or("NOOP");
256 let args = &self.text;
257 let line = if args.is_empty() {
258 format!("{verb}\r\n")
259 } else {
260 format!("{verb} {args}\r\n")
261 };
262 line.into_bytes()
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn test_build_noop() {
272 let pkt = SmtpBuilder::new().noop().build();
273 assert_eq!(pkt, b"NOOP\r\n");
274 }
275
276 #[test]
277 fn test_build_ehlo() {
278 let pkt = SmtpBuilder::new().ehlo("client.example.com").build();
279 assert_eq!(pkt, b"EHLO client.example.com\r\n");
280 }
281
282 #[test]
283 fn test_build_mail_from() {
284 let pkt = SmtpBuilder::new().mail_from("user@example.com").build();
285 assert_eq!(pkt, b"MAIL FROM:<user@example.com>\r\n");
286 }
287
288 #[test]
289 fn test_build_rcpt_to() {
290 let pkt = SmtpBuilder::new().rcpt_to("dest@example.com").build();
291 assert_eq!(pkt, b"RCPT TO:<dest@example.com>\r\n");
292 }
293
294 #[test]
295 fn test_build_data_command() {
296 let pkt = SmtpBuilder::new().data().build();
297 assert_eq!(pkt, b"DATA\r\n");
298 }
299
300 #[test]
301 fn test_build_quit() {
302 let pkt = SmtpBuilder::new().quit().build();
303 assert_eq!(pkt, b"QUIT\r\n");
304 }
305
306 #[test]
307 fn test_build_service_ready() {
308 let pkt = SmtpBuilder::new().service_ready("mail.example.com").build();
309 assert_eq!(pkt, b"220 mail.example.com ESMTP\r\n");
310 }
311
312 #[test]
313 fn test_build_ok() {
314 let pkt = SmtpBuilder::new().ok("OK").build();
315 assert_eq!(pkt, b"250 OK\r\n");
316 }
317
318 #[test]
319 fn test_build_start_mail_input() {
320 let pkt = SmtpBuilder::new().start_mail_input().build();
321 assert_eq!(pkt, b"354 Start mail input; end with <CRLF>.<CRLF>\r\n");
322 }
323
324 #[test]
325 fn test_build_ehlo_multiline_response() {
326 let pkt = SmtpBuilder::new()
327 .ehlo_response(
328 "mail.example.com",
329 vec![
330 "PIPELINING".to_string(),
331 "SIZE 10485760".to_string(),
332 "AUTH LOGIN PLAIN".to_string(),
333 ],
334 )
335 .build();
336 let s = String::from_utf8(pkt).unwrap();
337 assert!(s.starts_with("250-mail.example.com\r\n"));
338 assert!(s.contains("250-PIPELINING\r\n"));
339 assert!(s.contains("250-SIZE 10485760\r\n"));
340 assert!(s.ends_with("250 OK\r\n"));
341 }
342
343 #[test]
344 fn test_build_auth() {
345 let pkt = SmtpBuilder::new().auth("LOGIN", "").build();
346 assert_eq!(pkt, b"AUTH LOGIN\r\n");
347 }
348
349 #[test]
350 fn test_build_starttls() {
351 let pkt = SmtpBuilder::new().starttls().build();
352 assert_eq!(pkt, b"STARTTLS\r\n");
353 }
354}