Skip to main content

stackforge_core/layer/smtp/
builder.rs

1//! SMTP packet builder.
2//!
3//! Provides a fluent API for constructing SMTP commands and replies.
4//!
5//! # Examples
6//!
7//! ```rust
8//! use stackforge_core::layer::smtp::builder::SmtpBuilder;
9//!
10//! // Build an EHLO command
11//! let pkt = SmtpBuilder::new().ehlo("client.example.com").build();
12//! assert_eq!(pkt, b"EHLO client.example.com\r\n");
13//!
14//! // Build a 250 OK reply
15//! let pkt = SmtpBuilder::new().ok("OK").build();
16//! assert_eq!(pkt, b"250 OK\r\n");
17//! ```
18
19/// Builder for SMTP messages (commands and replies).
20#[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    // ========================================================================
48    // Reply builders (server-side)
49    // ========================================================================
50
51    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    /// 220 Service ready (server greeting).
65    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    /// 221 Closing connection.
71    pub fn closing(self) -> Self {
72        self.reply(221, "Bye")
73    }
74
75    /// 235 Authentication successful.
76    pub fn auth_success(self) -> Self {
77        self.reply(235, "Authentication successful")
78    }
79
80    /// 250 OK (single-line).
81    pub fn ok(self, text: impl Into<String>) -> Self {
82        self.reply(250, text)
83    }
84
85    /// 250 OK (multi-line, EHLO response).
86    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    /// 334 Server challenge (AUTH).
96    pub fn auth_challenge(self, challenge: impl Into<String>) -> Self {
97        self.reply(334, challenge)
98    }
99
100    /// 354 Start mail input.
101    pub fn start_mail_input(self) -> Self {
102        self.reply(354, "Start mail input; end with <CRLF>.<CRLF>")
103    }
104
105    /// 421 Service unavailable.
106    pub fn service_unavailable(self) -> Self {
107        self.reply(421, "Service not available")
108    }
109
110    /// 450 Mailbox temporarily unavailable.
111    pub fn mailbox_temp_unavailable(self, text: impl Into<String>) -> Self {
112        self.reply(450, text)
113    }
114
115    /// 530 Authentication required.
116    pub fn auth_required(self) -> Self {
117        self.reply(530, "Authentication required")
118    }
119
120    /// 535 Authentication failed.
121    pub fn auth_failed(self) -> Self {
122        self.reply(535, "Authentication credentials invalid")
123    }
124
125    /// 550 Mailbox not found.
126    pub fn mailbox_not_found(self, text: impl Into<String>) -> Self {
127        self.reply(550, text)
128    }
129
130    // ========================================================================
131    // Command builders (client-side)
132    // ========================================================================
133
134    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    /// EHLO <domain> (Extended HELLO, RFC 5321)
142    pub fn ehlo(self, domain: impl Into<String>) -> Self {
143        self.command("EHLO", domain)
144    }
145
146    /// HELO <domain>
147    pub fn helo(self, domain: impl Into<String>) -> Self {
148        self.command("HELO", domain)
149    }
150
151    /// MAIL FROM:<address>
152    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    /// RCPT TO:<address>
163    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    /// DATA (begin message body input)
174    pub fn data(self) -> Self {
175        self.command("DATA", "")
176    }
177
178    /// RSET (reset transaction)
179    pub fn rset(self) -> Self {
180        self.command("RSET", "")
181    }
182
183    /// VRFY <address>
184    pub fn vrfy(self, address: impl Into<String>) -> Self {
185        self.command("VRFY", address)
186    }
187
188    /// EXPN <list>
189    pub fn expn(self, list: impl Into<String>) -> Self {
190        self.command("EXPN", list)
191    }
192
193    /// HELP [command]
194    pub fn help(self, topic: impl Into<String>) -> Self {
195        self.command("HELP", topic)
196    }
197
198    /// NOOP
199    pub fn noop(self) -> Self {
200        self.command("NOOP", "")
201    }
202
203    /// QUIT
204    pub fn quit(self) -> Self {
205        self.command("QUIT", "")
206    }
207
208    /// AUTH <mechanism> [initial-response]
209    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    /// STARTTLS (upgrade to TLS, RFC 3207)
221    pub fn starttls(self) -> Self {
222        self.command("STARTTLS", "")
223    }
224
225    // ========================================================================
226    // Build
227    // ========================================================================
228
229    #[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                // Multi-line: intermediate lines use NNN-
244                out.extend_from_slice(format!("{code:03}-{line}\r\n").as_bytes());
245            }
246            // Last line uses space separator
247            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}