Skip to main content

rusmes_smtp/
parser.rs

1//! SMTP command parser using nom
2
3use crate::command::{MailParam, SmtpCommand};
4use nom::{
5    branch::alt,
6    bytes::complete::{tag_no_case, take_while1},
7    character::complete::{char, space0, space1},
8    combinator::{map, opt, rest},
9    sequence::{delimited, preceded},
10    IResult, Parser,
11};
12use rusmes_proto::MailAddress;
13
14/// Parse a complete SMTP command line
15pub fn parse_command(input: &str) -> Result<SmtpCommand, String> {
16    let input = input.trim();
17
18    // Try to parse each command type
19    if let Ok((_, cmd)) = smtp_command(input) {
20        Ok(cmd)
21    } else {
22        Err(format!("Failed to parse command: {}", input))
23    }
24}
25
26/// Parse any SMTP command
27fn smtp_command(input: &str) -> IResult<&str, SmtpCommand> {
28    alt((
29        helo_command,
30        ehlo_command,
31        mail_command,
32        rcpt_command,
33        data_command,
34        bdat_command,
35        rset_command,
36        noop_command,
37        quit_command,
38        vrfy_command,
39        expn_command,
40        help_command,
41        starttls_command,
42        auth_command,
43    ))
44    .parse(input)
45}
46
47/// Parse HELO command
48fn helo_command(input: &str) -> IResult<&str, SmtpCommand> {
49    map(
50        preceded(tag_no_case("HELO"), preceded(space1, domain)),
51        SmtpCommand::Helo,
52    )
53    .parse(input)
54}
55
56/// Parse EHLO command
57fn ehlo_command(input: &str) -> IResult<&str, SmtpCommand> {
58    map(
59        preceded(tag_no_case("EHLO"), preceded(space1, domain)),
60        SmtpCommand::Ehlo,
61    )
62    .parse(input)
63}
64
65/// Parse MAIL FROM command
66fn mail_command(input: &str) -> IResult<&str, SmtpCommand> {
67    let (input, _) = tag_no_case("MAIL FROM:").parse(input)?;
68    let (input, _) = space0(input)?;
69    let (input, from) = reverse_path(input)?;
70    let (input, params) = opt(preceded(space1, mail_parameters)).parse(input)?;
71
72    Ok((
73        input,
74        SmtpCommand::Mail {
75            from,
76            params: params.unwrap_or_default(),
77        },
78    ))
79}
80
81/// Parse RCPT TO command
82fn rcpt_command(input: &str) -> IResult<&str, SmtpCommand> {
83    let (input, _) = tag_no_case("RCPT TO:").parse(input)?;
84    let (input, _) = space0(input)?;
85    let (input, to) = forward_path(input)?;
86    let (input, params) = opt(preceded(space1, mail_parameters)).parse(input)?;
87
88    Ok((
89        input,
90        SmtpCommand::Rcpt {
91            to,
92            params: params.unwrap_or_default(),
93        },
94    ))
95}
96
97/// Parse DATA command
98fn data_command(input: &str) -> IResult<&str, SmtpCommand> {
99    map(tag_no_case("DATA"), |_| SmtpCommand::Data).parse(input)
100}
101
102/// Parse BDAT command
103fn bdat_command(input: &str) -> IResult<&str, SmtpCommand> {
104    use nom::character::complete::digit1;
105
106    let (input, _) = tag_no_case("BDAT").parse(input)?;
107    let (input, _) = space1(input)?;
108    let (input, size_str) = digit1(input)?;
109    let (input, last) = opt(preceded(space1, tag_no_case("LAST"))).parse(input)?;
110
111    // Parse chunk size
112    let chunk_size = size_str.parse::<usize>().map_err(|_| {
113        nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Digit))
114    })?;
115
116    Ok((
117        input,
118        SmtpCommand::Bdat {
119            chunk_size,
120            last: last.is_some(),
121        },
122    ))
123}
124
125/// Parse RSET command
126fn rset_command(input: &str) -> IResult<&str, SmtpCommand> {
127    map(tag_no_case("RSET"), |_| SmtpCommand::Rset).parse(input)
128}
129
130/// Parse NOOP command
131fn noop_command(input: &str) -> IResult<&str, SmtpCommand> {
132    map(tag_no_case("NOOP"), |_| SmtpCommand::Noop).parse(input)
133}
134
135/// Parse QUIT command
136fn quit_command(input: &str) -> IResult<&str, SmtpCommand> {
137    map(tag_no_case("QUIT"), |_| SmtpCommand::Quit).parse(input)
138}
139
140/// Parse VRFY command
141fn vrfy_command(input: &str) -> IResult<&str, SmtpCommand> {
142    map(
143        preceded(tag_no_case("VRFY"), preceded(space1, rest)),
144        |s: &str| SmtpCommand::Vrfy(s.to_string()),
145    )
146    .parse(input)
147}
148
149/// Parse EXPN command
150fn expn_command(input: &str) -> IResult<&str, SmtpCommand> {
151    map(
152        preceded(tag_no_case("EXPN"), preceded(space1, rest)),
153        |s: &str| SmtpCommand::Expn(s.to_string()),
154    )
155    .parse(input)
156}
157
158/// Parse HELP command
159fn help_command(input: &str) -> IResult<&str, SmtpCommand> {
160    map(
161        preceded(tag_no_case("HELP"), opt(preceded(space1, rest))),
162        |s: Option<&str>| SmtpCommand::Help(s.map(|x| x.to_string())),
163    )
164    .parse(input)
165}
166
167/// Parse STARTTLS command
168fn starttls_command(input: &str) -> IResult<&str, SmtpCommand> {
169    map(tag_no_case("STARTTLS"), |_| SmtpCommand::StartTls).parse(input)
170}
171
172/// Parse AUTH command
173fn auth_command(input: &str) -> IResult<&str, SmtpCommand> {
174    let (input, _) = tag_no_case("AUTH").parse(input)?;
175    let (input, _) = space1(input)?;
176    let (input, mechanism) =
177        take_while1(|c: char| c.is_ascii_alphanumeric() || c == '-').parse(input)?;
178    let (input, initial_response) = opt(preceded(space1, rest)).parse(input)?;
179
180    Ok((
181        input,
182        SmtpCommand::Auth {
183            mechanism: mechanism.to_string(),
184            initial_response: initial_response.map(|s| s.to_string()),
185        },
186    ))
187}
188
189/// Parse reverse-path (MAIL FROM)
190fn reverse_path(input: &str) -> IResult<&str, MailAddress> {
191    delimited(char('<'), mailbox, char('>')).parse(input)
192}
193
194/// Parse forward-path (RCPT TO)
195fn forward_path(input: &str) -> IResult<&str, MailAddress> {
196    delimited(char('<'), mailbox, char('>')).parse(input)
197}
198
199/// Parse mailbox (email address)
200fn mailbox(input: &str) -> IResult<&str, MailAddress> {
201    let (input, addr_str) = take_while1(|c: char| {
202        c.is_ascii_alphanumeric() || c == '@' || c == '.' || c == '-' || c == '_' || c == '+'
203    })
204    .parse(input)?;
205
206    // Parse the address string
207    match addr_str.parse::<MailAddress>() {
208        Ok(addr) => Ok((input, addr)),
209        Err(_) => Err(nom::Err::Error(nom::error::Error::new(
210            input,
211            nom::error::ErrorKind::Verify,
212        ))),
213    }
214}
215
216/// Parse domain name
217fn domain(input: &str) -> IResult<&str, String> {
218    map(
219        take_while1(|c: char| c.is_ascii_alphanumeric() || c == '.' || c == '-'),
220        |s: &str| s.to_string(),
221    )
222    .parse(input)
223}
224
225/// Parse mail parameters (ESMTP)
226fn mail_parameters(input: &str) -> IResult<&str, Vec<MailParam>> {
227    let mut params = Vec::new();
228    let mut remaining = input;
229
230    while let Ok((rest, param)) = mail_parameter(remaining) {
231        params.push(param);
232        remaining = rest;
233
234        // Skip any spaces before checking for more parameters
235        remaining = remaining.trim_start();
236
237        // If we have more content, continue parsing
238        if remaining.is_empty() {
239            break;
240        }
241    }
242
243    Ok((remaining, params))
244}
245
246/// Parse a single mail parameter
247fn mail_parameter(input: &str) -> IResult<&str, MailParam> {
248    let (input, keyword) =
249        take_while1(|c: char| c.is_ascii_alphanumeric() || c == '-').parse(input)?;
250    let (input, value) = opt(preceded(char('='), parameter_value)).parse(input)?;
251
252    Ok((
253        input,
254        MailParam::new(keyword.to_string(), value.map(|s| s.to_string())),
255    ))
256}
257
258/// Parse parameter value
259fn parameter_value(input: &str) -> IResult<&str, String> {
260    map(
261        take_while1(|c: char| c.is_ascii_alphanumeric() || c == '-' || c == '.'),
262        |s: &str| s.to_string(),
263    )
264    .parse(input)
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_parse_helo() {
273        let cmd = parse_command("HELO example.com").expect("HELO command parse");
274        assert!(matches!(cmd, SmtpCommand::Helo(domain) if domain == "example.com"));
275    }
276
277    #[test]
278    fn test_parse_ehlo() {
279        let cmd = parse_command("EHLO mail.example.com").expect("EHLO command parse");
280        assert!(matches!(cmd, SmtpCommand::Ehlo(domain) if domain == "mail.example.com"));
281    }
282
283    #[test]
284    fn test_parse_mail_from() {
285        let cmd = parse_command("MAIL FROM:<user@example.com>").expect("MAIL FROM parse");
286        match cmd {
287            SmtpCommand::Mail { from, .. } => {
288                assert_eq!(from.as_string(), "user@example.com");
289            }
290            _ => panic!("Expected Mail command"),
291        }
292    }
293
294    #[test]
295    fn test_parse_rcpt_to() {
296        let cmd = parse_command("RCPT TO:<recipient@example.com>").expect("RCPT TO parse");
297        match cmd {
298            SmtpCommand::Rcpt { to, .. } => {
299                assert_eq!(to.as_string(), "recipient@example.com");
300            }
301            _ => panic!("Expected Rcpt command"),
302        }
303    }
304
305    #[test]
306    fn test_parse_data() {
307        let cmd = parse_command("DATA").expect("DATA command parse");
308        assert!(matches!(cmd, SmtpCommand::Data));
309    }
310
311    #[test]
312    fn test_parse_quit() {
313        let cmd = parse_command("QUIT").expect("QUIT command parse");
314        assert!(matches!(cmd, SmtpCommand::Quit));
315    }
316
317    #[test]
318    fn test_parse_rset() {
319        let cmd = parse_command("RSET").expect("RSET command parse");
320        assert!(matches!(cmd, SmtpCommand::Rset));
321    }
322
323    #[test]
324    fn test_parse_starttls() {
325        let cmd = parse_command("STARTTLS").expect("STARTTLS command parse");
326        assert!(matches!(cmd, SmtpCommand::StartTls));
327    }
328
329    #[test]
330    fn test_parse_auth() {
331        let cmd = parse_command("AUTH PLAIN dGVzdA==").expect("AUTH PLAIN command parse");
332        match cmd {
333            SmtpCommand::Auth {
334                mechanism,
335                initial_response,
336            } => {
337                assert_eq!(mechanism, "PLAIN");
338                assert_eq!(initial_response, Some("dGVzdA==".to_string()));
339            }
340            _ => panic!("Expected Auth command"),
341        }
342    }
343
344    #[test]
345    fn test_parse_case_insensitive() {
346        let cmd1 = parse_command("quit").expect("lowercase quit parse");
347        let cmd2 = parse_command("QUIT").expect("uppercase QUIT parse");
348        let cmd3 = parse_command("QuIt").expect("mixed-case QuIt parse");
349
350        assert!(matches!(cmd1, SmtpCommand::Quit));
351        assert!(matches!(cmd2, SmtpCommand::Quit));
352        assert!(matches!(cmd3, SmtpCommand::Quit));
353    }
354
355    #[test]
356    fn test_parse_mail_with_size() {
357        let cmd = parse_command("MAIL FROM:<user@example.com> SIZE=12345")
358            .expect("MAIL FROM with SIZE param parse");
359        match cmd {
360            SmtpCommand::Mail { from, params } => {
361                assert_eq!(from.as_string(), "user@example.com");
362                assert_eq!(params.len(), 1);
363                assert_eq!(params[0].keyword, "SIZE");
364                assert_eq!(params[0].value, Some("12345".to_string()));
365            }
366            _ => panic!("Expected Mail command"),
367        }
368    }
369
370    #[test]
371    fn test_parse_mail_with_body() {
372        let cmd = parse_command("MAIL FROM:<user@example.com> BODY=8BITMIME")
373            .expect("MAIL FROM with BODY param parse");
374        match cmd {
375            SmtpCommand::Mail { from, params } => {
376                assert_eq!(from.as_string(), "user@example.com");
377                assert_eq!(params.len(), 1);
378                assert_eq!(params[0].keyword, "BODY");
379                assert_eq!(params[0].value, Some("8BITMIME".to_string()));
380            }
381            _ => panic!("Expected Mail command"),
382        }
383    }
384
385    #[test]
386    fn test_parse_mail_with_smtputf8() {
387        let cmd = parse_command("MAIL FROM:<user@example.com> SMTPUTF8")
388            .expect("MAIL FROM with SMTPUTF8 param parse");
389        match cmd {
390            SmtpCommand::Mail { from, params } => {
391                assert_eq!(from.as_string(), "user@example.com");
392                assert_eq!(params.len(), 1);
393                assert_eq!(params[0].keyword, "SMTPUTF8");
394                assert_eq!(params[0].value, None);
395            }
396            _ => panic!("Expected Mail command"),
397        }
398    }
399
400    #[test]
401    fn test_parse_mail_with_multiple_params() {
402        let cmd = parse_command("MAIL FROM:<user@example.com> SIZE=12345 BODY=8BITMIME SMTPUTF8")
403            .expect("MAIL FROM with multiple params parse");
404        match cmd {
405            SmtpCommand::Mail { from, params } => {
406                assert_eq!(from.as_string(), "user@example.com");
407                assert_eq!(params.len(), 3);
408                assert_eq!(params[0].keyword, "SIZE");
409                assert_eq!(params[0].value, Some("12345".to_string()));
410                assert_eq!(params[1].keyword, "BODY");
411                assert_eq!(params[1].value, Some("8BITMIME".to_string()));
412                assert_eq!(params[2].keyword, "SMTPUTF8");
413                assert_eq!(params[2].value, None);
414            }
415            _ => panic!("Expected Mail command"),
416        }
417    }
418
419    #[test]
420    fn test_parse_bdat() {
421        let cmd = parse_command("BDAT 1024").expect("BDAT without LAST parse");
422        match cmd {
423            SmtpCommand::Bdat { chunk_size, last } => {
424                assert_eq!(chunk_size, 1024);
425                assert!(!last);
426            }
427            _ => panic!("Expected Bdat command"),
428        }
429    }
430
431    #[test]
432    fn test_parse_bdat_last() {
433        let cmd = parse_command("BDAT 512 LAST").expect("BDAT with LAST parse");
434        match cmd {
435            SmtpCommand::Bdat { chunk_size, last } => {
436                assert_eq!(chunk_size, 512);
437                assert!(last);
438            }
439            _ => panic!("Expected Bdat command"),
440        }
441    }
442
443    #[test]
444    fn test_parse_bdat_case_insensitive() {
445        let cmd1 = parse_command("bdat 100").expect("lowercase bdat parse");
446        let cmd2 = parse_command("BDAT 100").expect("uppercase BDAT parse");
447        let cmd3 = parse_command("BdAt 100").expect("mixed-case BdAt parse");
448        let cmd4 = parse_command("BDAT 256 last").expect("BDAT with lowercase last parse");
449        let cmd5 = parse_command("bdat 256 LAST").expect("bdat with uppercase LAST parse");
450
451        match (cmd1, cmd2, cmd3, cmd4, cmd5) {
452            (
453                SmtpCommand::Bdat {
454                    chunk_size: s1,
455                    last: l1,
456                },
457                SmtpCommand::Bdat {
458                    chunk_size: s2,
459                    last: l2,
460                },
461                SmtpCommand::Bdat {
462                    chunk_size: s3,
463                    last: l3,
464                },
465                SmtpCommand::Bdat {
466                    chunk_size: s4,
467                    last: l4,
468                },
469                SmtpCommand::Bdat {
470                    chunk_size: s5,
471                    last: l5,
472                },
473            ) => {
474                assert_eq!(s1, 100);
475                assert_eq!(s2, 100);
476                assert_eq!(s3, 100);
477                assert_eq!(s4, 256);
478                assert_eq!(s5, 256);
479                assert!(!l1);
480                assert!(!l2);
481                assert!(!l3);
482                assert!(l4);
483                assert!(l5);
484            }
485            _ => panic!("Expected Bdat commands"),
486        }
487    }
488}