Skip to main content

stackforge_core/layer/smtp/
mod.rs

1//! SMTP (Simple Mail Transfer Protocol) layer implementation.
2//!
3//! Implements RFC 5321 SMTP and RFC 1869 ESMTP packet parsing as a zero-copy
4//! view into a packet buffer.
5//!
6//! ## Protocol Overview
7//!
8//! SMTP is a text-based protocol operating over TCP. Standard ports:
9//! - **25**: MTA-to-MTA relay
10//! - **587**: Client submission (RFC 6409)
11//! - **465**: SMTPS (deprecated, but still widely used)
12//!
13//! ## Packet Format
14//!
15//! **Client Command:**
16//! ```text
17//! VERB [parameters]\r\n
18//! ```
19//!
20//! **Server Reply:**
21//! ```text
22//! NNN<SP>text\r\n              (single-line)
23//! NNN-text\r\n ... NNN<SP>text\r\n  (multi-line)
24//! ```
25//!
26//! ## SMTP Commands (RFC 5321 §4.1)
27//!
28//! | Command    | Description                        |
29//! |------------|------------------------------------|
30//! | EHLO       | Extended HELLO (ESMTP)             |
31//! | HELO       | HELLO                              |
32//! | MAIL       | Begin mail transaction (FROM)      |
33//! | RCPT       | Identify recipient (TO)            |
34//! | DATA       | Begin message data                 |
35//! | RSET       | Reset transaction                  |
36//! | VRFY       | Verify address                     |
37//! | EXPN       | Expand mailing list                |
38//! | HELP       | Help information                   |
39//! | NOOP       | No operation                       |
40//! | QUIT       | Terminate connection               |
41//! | AUTH       | Authenticate (RFC 4954)            |
42//! | STARTTLS   | Start TLS negotiation (RFC 3207)   |
43
44pub mod builder;
45pub use builder::SmtpBuilder;
46
47use crate::layer::field::{FieldError, FieldValue};
48use crate::layer::{Layer, LayerIndex, LayerKind};
49
50/// Minimum SMTP payload size.
51pub const SMTP_MIN_HEADER_LEN: usize = 4;
52
53/// SMTP standard relay port.
54pub const SMTP_PORT: u16 = 25;
55
56/// SMTP submission port (RFC 6409).
57pub const SMTP_SUBMISSION_PORT: u16 = 587;
58
59/// SMTPS (over TLS) port.
60pub const SMTPS_PORT: u16 = 465;
61
62// ============================================================================
63// SMTP Reply code constants (RFC 5321 §4.2)
64// ============================================================================
65pub const REPLY_SYSTEM_STATUS: u16 = 211;
66pub const REPLY_HELP: u16 = 214;
67pub const REPLY_SERVICE_READY: u16 = 220;
68pub const REPLY_CLOSING: u16 = 221;
69pub const REPLY_AUTH_SUCCESS: u16 = 235;
70pub const REPLY_OK: u16 = 250;
71pub const REPLY_USER_NOT_LOCAL: u16 = 251;
72pub const REPLY_CANNOT_VRFY: u16 = 252;
73pub const REPLY_AUTH_INPUT: u16 = 334;
74pub const REPLY_DATA_INPUT: u16 = 354;
75pub const REPLY_SERVICE_UNAVAIL: u16 = 421;
76pub const REPLY_MAILBOX_UNAVAIL: u16 = 450;
77pub const REPLY_LOCAL_ERROR: u16 = 451;
78pub const REPLY_INSUFF_STORAGE: u16 = 452;
79pub const REPLY_TEMP_AUTH_FAIL: u16 = 454;
80pub const REPLY_CMD_UNRECOGNIZED: u16 = 500;
81pub const REPLY_ARG_SYNTAX_ERROR: u16 = 501;
82pub const REPLY_CMD_NOT_IMPL: u16 = 502;
83pub const REPLY_BAD_CMD_SEQUENCE: u16 = 503;
84pub const REPLY_CMD_NOT_IMPL_PARAM: u16 = 504;
85pub const REPLY_AUTH_REQUIRED: u16 = 530;
86pub const REPLY_AUTH_FAILED: u16 = 535;
87pub const REPLY_MAILBOX_UNAVAIL_PERM: u16 = 550;
88pub const REPLY_USER_NOT_LOCAL_PERM: u16 = 551;
89pub const REPLY_EXCEED_STORAGE: u16 = 552;
90pub const REPLY_MAILBOX_NAME_INVALID: u16 = 553;
91pub const REPLY_TRANSACTION_FAILED: u16 = 554;
92
93// ============================================================================
94// SMTP Command verbs
95// ============================================================================
96pub const CMD_EHLO: &str = "EHLO";
97pub const CMD_HELO: &str = "HELO";
98pub const CMD_MAIL: &str = "MAIL";
99pub const CMD_RCPT: &str = "RCPT";
100pub const CMD_DATA: &str = "DATA";
101pub const CMD_RSET: &str = "RSET";
102pub const CMD_VRFY: &str = "VRFY";
103pub const CMD_EXPN: &str = "EXPN";
104pub const CMD_HELP: &str = "HELP";
105pub const CMD_NOOP: &str = "NOOP";
106pub const CMD_QUIT: &str = "QUIT";
107pub const CMD_AUTH: &str = "AUTH";
108pub const CMD_STARTTLS: &str = "STARTTLS";
109pub const CMD_BDAT: &str = "BDAT";
110
111pub static SMTP_COMMANDS: &[&str] = &[
112    "EHLO", "HELO", "MAIL", "RCPT", "DATA", "RSET", "VRFY", "EXPN", "HELP", "NOOP", "QUIT", "AUTH",
113    "STARTTLS", "BDAT",
114];
115
116/// Field names for Python/generic access.
117pub static SMTP_FIELD_NAMES: &[&str] = &[
118    "command",
119    "args",
120    "reply_code",
121    "reply_text",
122    "is_response",
123    "is_multiline",
124    "mailfrom",
125    "rcptto",
126    "raw",
127];
128
129// ============================================================================
130// Payload detection
131// ============================================================================
132
133/// Returns true if `buf` looks like an SMTP control-connection payload.
134#[must_use]
135pub fn is_smtp_payload(buf: &[u8]) -> bool {
136    if buf.len() < 3 {
137        return false;
138    }
139    // Check for SMTP reply (3-digit code)
140    if buf[0].is_ascii_digit() && buf[1].is_ascii_digit() && buf[2].is_ascii_digit() {
141        return buf.len() < 4 || matches!(buf[3], b' ' | b'-' | b'\r' | b'\n');
142    }
143    // Check for SMTP commands
144    if let Ok(text) = std::str::from_utf8(buf) {
145        let upper = text.to_ascii_uppercase();
146        let first_word = upper.split_ascii_whitespace().next().unwrap_or("");
147        return SMTP_COMMANDS.contains(&first_word);
148    }
149    false
150}
151
152// ============================================================================
153// SmtpLayer - zero-copy view
154// ============================================================================
155
156/// A zero-copy view into an SMTP layer within a packet buffer.
157#[must_use]
158#[derive(Debug, Clone)]
159pub struct SmtpLayer {
160    pub index: LayerIndex,
161}
162
163impl SmtpLayer {
164    pub fn new(index: LayerIndex) -> Self {
165        Self { index }
166    }
167
168    pub fn at_start(len: usize) -> Self {
169        Self {
170            index: LayerIndex::new(LayerKind::Smtp, 0, len),
171        }
172    }
173
174    #[inline]
175    fn slice<'a>(&self, buf: &'a [u8]) -> &'a [u8] {
176        let end = self.index.end.min(buf.len());
177        &buf[self.index.start..end]
178    }
179
180    fn first_line<'a>(&self, buf: &'a [u8]) -> &'a str {
181        let s = self.slice(buf);
182        let text = std::str::from_utf8(s).unwrap_or("");
183        text.lines().next().unwrap_or("").trim_end_matches('\r')
184    }
185
186    /// Returns true if this message is a server reply (3-digit code).
187    #[must_use]
188    pub fn is_response(&self, buf: &[u8]) -> bool {
189        let s = self.slice(buf);
190        s.len() >= 3 && s[0].is_ascii_digit() && s[1].is_ascii_digit() && s[2].is_ascii_digit()
191    }
192
193    /// Returns true if this is a multi-line reply.
194    #[must_use]
195    pub fn is_multiline(&self, buf: &[u8]) -> bool {
196        let s = self.slice(buf);
197        s.len() >= 4
198            && s[0].is_ascii_digit()
199            && s[1].is_ascii_digit()
200            && s[2].is_ascii_digit()
201            && s[3] == b'-'
202    }
203
204    /// Returns the 3-digit reply code.
205    ///
206    /// # Errors
207    ///
208    /// Returns [`FieldError::BufferTooShort`] if fewer than 3 bytes are available,
209    /// or [`FieldError::InvalidValue`] if the first 3 bytes are not ASCII digits.
210    pub fn reply_code(&self, buf: &[u8]) -> Result<u16, FieldError> {
211        let s = self.slice(buf);
212        if s.len() < 3 {
213            return Err(FieldError::BufferTooShort {
214                offset: self.index.start,
215                need: 3,
216                have: s.len(),
217            });
218        }
219        if s[0].is_ascii_digit() && s[1].is_ascii_digit() && s[2].is_ascii_digit() {
220            Ok(u16::from(s[0] - b'0') * 100 + u16::from(s[1] - b'0') * 10 + u16::from(s[2] - b'0'))
221        } else {
222            Err(FieldError::InvalidValue(
223                "reply_code: not a valid 3-digit reply code".into(),
224            ))
225        }
226    }
227
228    /// Returns the reply text (after the code and separator).
229    ///
230    /// # Errors
231    ///
232    /// Returns [`FieldError::InvalidValue`] if the payload does not begin with a
233    /// valid 3-digit reply code.
234    pub fn reply_text(&self, buf: &[u8]) -> Result<String, FieldError> {
235        let line = self.first_line(buf);
236        if line.len() >= 4 {
237            Ok(line[4..].to_string())
238        } else if line.len() == 3 {
239            Ok(String::new())
240        } else {
241            Err(FieldError::InvalidValue(
242                "reply_text: invalid reply format".into(),
243            ))
244        }
245    }
246
247    /// Returns the command verb (for client commands).
248    ///
249    /// # Errors
250    ///
251    /// Returns [`FieldError::InvalidValue`] if the payload is not valid UTF-8.
252    pub fn command(&self, buf: &[u8]) -> Result<String, FieldError> {
253        let s = self.slice(buf);
254        let text = std::str::from_utf8(s)
255            .map_err(|_| FieldError::InvalidValue("command: non-UTF8 payload".into()))?;
256        let word = text.split_ascii_whitespace().next().unwrap_or("");
257        Ok(word.to_ascii_uppercase())
258    }
259
260    /// Returns the command arguments.
261    ///
262    /// # Errors
263    ///
264    /// Returns [`FieldError::InvalidValue`] if the payload is not valid UTF-8.
265    pub fn args(&self, buf: &[u8]) -> Result<String, FieldError> {
266        let s = self.slice(buf);
267        let text = std::str::from_utf8(s)
268            .map_err(|_| FieldError::InvalidValue("args: non-UTF8 payload".into()))?;
269        let first_line = text.lines().next().unwrap_or("");
270        let rest = first_line
271            .split_once(' ')
272            .map_or("", |(_, r)| r)
273            .trim_end_matches(['\r', '\n']);
274        Ok(rest.to_string())
275    }
276
277    /// Extracts the MAIL FROM address from a MAIL command.
278    ///
279    /// Input: `MAIL FROM:<user@example.com>`
280    /// Output: `user@example.com`
281    ///
282    /// # Errors
283    ///
284    /// Returns [`FieldError::InvalidValue`] if the payload is not a `MAIL FROM` command.
285    pub fn mailfrom(&self, buf: &[u8]) -> Result<String, FieldError> {
286        let args = self.args(buf)?;
287        let upper_args = args.to_ascii_uppercase();
288        if !upper_args.starts_with("FROM:") {
289            return Err(FieldError::InvalidValue(
290                "mailfrom: not a MAIL FROM command".into(),
291            ));
292        }
293        let addr_part = &args[5..]; // skip "FROM:"
294        Ok(extract_angle_address(addr_part))
295    }
296
297    /// Extracts the RCPT TO address from a RCPT command.
298    ///
299    /// Input: `RCPT TO:<user@example.com>`
300    /// Output: `user@example.com`
301    ///
302    /// # Errors
303    ///
304    /// Returns [`FieldError::InvalidValue`] if the payload is not a `RCPT TO` command.
305    pub fn rcptto(&self, buf: &[u8]) -> Result<String, FieldError> {
306        let args = self.args(buf)?;
307        let upper_args = args.to_ascii_uppercase();
308        if !upper_args.starts_with("TO:") {
309            return Err(FieldError::InvalidValue(
310                "rcptto: not a RCPT TO command".into(),
311            ));
312        }
313        let addr_part = &args[3..]; // skip "TO:"
314        Ok(extract_angle_address(addr_part))
315    }
316
317    /// Returns the raw payload as a string.
318    #[must_use]
319    pub fn raw(&self, buf: &[u8]) -> String {
320        let s = self.slice(buf);
321        String::from_utf8_lossy(s).to_string()
322    }
323
324    pub fn get_field(&self, buf: &[u8], name: &str) -> Option<Result<FieldValue, FieldError>> {
325        match name {
326            "command" => Some(self.command(buf).map(FieldValue::Str)),
327            "args" => Some(self.args(buf).map(FieldValue::Str)),
328            "reply_code" => Some(self.reply_code(buf).map(FieldValue::U16)),
329            "reply_text" => Some(self.reply_text(buf).map(FieldValue::Str)),
330            "is_response" => Some(Ok(FieldValue::Bool(self.is_response(buf)))),
331            "is_multiline" => Some(Ok(FieldValue::Bool(self.is_multiline(buf)))),
332            "mailfrom" => Some(self.mailfrom(buf).map(FieldValue::Str)),
333            "rcptto" => Some(self.rcptto(buf).map(FieldValue::Str)),
334            "raw" => Some(Ok(FieldValue::Str(self.raw(buf)))),
335            _ => None,
336        }
337    }
338}
339
340/// Extract an email address from angle brackets `<user@example.com>` or bare.
341fn extract_angle_address(s: &str) -> String {
342    let s = s.trim();
343    if let (Some(start), Some(end)) = (s.find('<'), s.rfind('>')) {
344        s[start + 1..end].to_string()
345    } else {
346        s.to_string()
347    }
348}
349
350impl Layer for SmtpLayer {
351    fn kind(&self) -> LayerKind {
352        LayerKind::Smtp
353    }
354
355    fn summary(&self, buf: &[u8]) -> String {
356        let s = self.slice(buf);
357        let text = String::from_utf8_lossy(s);
358        let first_line = text.lines().next().unwrap_or("").trim_end_matches('\r');
359        format!("SMTP {first_line}")
360    }
361
362    fn header_len(&self, buf: &[u8]) -> usize {
363        self.slice(buf).len()
364    }
365
366    fn hashret(&self, buf: &[u8]) -> Vec<u8> {
367        if let Ok(code) = self.reply_code(buf) {
368            code.to_be_bytes().to_vec()
369        } else if let Ok(cmd) = self.command(buf) {
370            cmd.into_bytes()
371        } else {
372            vec![]
373        }
374    }
375
376    fn field_names(&self) -> &'static [&'static str] {
377        SMTP_FIELD_NAMES
378    }
379}
380
381/// Returns a human-readable display of SMTP layer fields.
382#[must_use]
383pub fn smtp_show_fields(l: &SmtpLayer, buf: &[u8]) -> Vec<(&'static str, String)> {
384    let mut fields = Vec::new();
385    if l.is_response(buf) {
386        if let Ok(code) = l.reply_code(buf) {
387            fields.push(("reply_code", code.to_string()));
388        }
389        if let Ok(text) = l.reply_text(buf) {
390            fields.push(("reply_text", text));
391        }
392        fields.push(("is_multiline", l.is_multiline(buf).to_string()));
393    } else if let Ok(cmd) = l.command(buf) {
394        fields.push(("command", cmd));
395        if let Ok(args) = l.args(buf)
396            && !args.is_empty()
397        {
398            fields.push(("args", args));
399        }
400    }
401    fields
402}
403
404/// Returns a description for an SMTP reply code.
405#[must_use]
406pub fn reply_code_description(code: u16) -> &'static str {
407    match code {
408        211 => "System status, or system help reply",
409        214 => "Help message",
410        220 => "Service ready",
411        221 => "Service closing transmission channel",
412        235 => "Authentication successful",
413        250 => "Requested mail action okay, completed",
414        251 => "User not local; will forward",
415        252 => "Cannot VRFY user, but will accept message",
416        334 => "Server challenge (AUTH)",
417        354 => "Start mail input; end with <CRLF>.<CRLF>",
418        421 => "Service not available, closing channel",
419        450 => "Requested mail action not taken: mailbox unavailable",
420        451 => "Requested action aborted: local error",
421        452 => "Requested action not taken: insufficient storage",
422        454 => "Temporary authentication failure",
423        500 => "Syntax error, command unrecognized",
424        501 => "Syntax error in parameters or arguments",
425        502 => "Command not implemented",
426        503 => "Bad sequence of commands",
427        504 => "Command parameter not implemented",
428        530 => "Authentication required",
429        535 => "Authentication credentials invalid",
430        550 => "Requested action not taken: mailbox unavailable",
431        551 => "User not local; please try forwarding",
432        552 => "Requested mail action aborted: exceeded storage allocation",
433        553 => "Requested action not taken: mailbox name not allowed",
434        554 => "Transaction failed",
435        _ => "Unknown reply code",
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::layer::LayerIndex;
443
444    fn make_layer(data: &[u8]) -> SmtpLayer {
445        SmtpLayer::new(LayerIndex::new(LayerKind::Smtp, 0, data.len()))
446    }
447
448    #[test]
449    fn test_smtp_detection_reply() {
450        assert!(is_smtp_payload(b"220 mail.example.com ESMTP\r\n"));
451        assert!(is_smtp_payload(b"250 OK\r\n"));
452        assert!(is_smtp_payload(b"354 Start mail input\r\n"));
453        assert!(is_smtp_payload(b"550 Mailbox not found\r\n"));
454    }
455
456    #[test]
457    fn test_smtp_detection_command() {
458        assert!(is_smtp_payload(b"EHLO example.com\r\n"));
459        assert!(is_smtp_payload(b"MAIL FROM:<user@example.com>\r\n"));
460        assert!(is_smtp_payload(b"RCPT TO:<dest@example.com>\r\n"));
461        assert!(is_smtp_payload(b"DATA\r\n"));
462        assert!(is_smtp_payload(b"QUIT\r\n"));
463    }
464
465    #[test]
466    fn test_smtp_detection_negative() {
467        assert!(!is_smtp_payload(b""));
468        assert!(!is_smtp_payload(b"GET / HTTP/1.1"));
469        assert!(!is_smtp_payload(b"\x00\x01"));
470    }
471
472    #[test]
473    fn test_smtp_layer_reply() {
474        let data = b"220 mail.example.com ESMTP Postfix\r\n";
475        let layer = make_layer(data);
476        assert!(layer.is_response(data));
477        assert_eq!(layer.reply_code(data).unwrap(), 220);
478        assert!(layer.reply_text(data).unwrap().contains("ESMTP"));
479    }
480
481    #[test]
482    fn test_smtp_layer_multiline() {
483        let data = b"250-mail.example.com\r\n250-PIPELINING\r\n250 OK\r\n";
484        let layer = make_layer(data);
485        assert!(layer.is_multiline(data));
486        assert_eq!(layer.reply_code(data).unwrap(), 250);
487    }
488
489    #[test]
490    fn test_smtp_layer_command() {
491        let data = b"EHLO client.example.com\r\n";
492        let layer = make_layer(data);
493        assert!(!layer.is_response(data));
494        assert_eq!(layer.command(data).unwrap(), "EHLO");
495        assert_eq!(layer.args(data).unwrap(), "client.example.com");
496    }
497
498    #[test]
499    fn test_smtp_layer_mail_from() {
500        let data = b"MAIL FROM:<sender@example.com>\r\n";
501        let layer = make_layer(data);
502        assert_eq!(layer.command(data).unwrap(), "MAIL");
503        assert_eq!(layer.mailfrom(data).unwrap(), "sender@example.com");
504    }
505
506    #[test]
507    fn test_smtp_layer_rcpt_to() {
508        let data = b"RCPT TO:<recipient@example.com>\r\n";
509        let layer = make_layer(data);
510        assert_eq!(layer.command(data).unwrap(), "RCPT");
511        assert_eq!(layer.rcptto(data).unwrap(), "recipient@example.com");
512    }
513
514    #[test]
515    fn test_smtp_field_access() {
516        let data = b"250 OK\r\n";
517        let layer = make_layer(data);
518        assert!(matches!(
519            layer.get_field(data, "reply_code"),
520            Some(Ok(FieldValue::U16(250)))
521        ));
522        assert!(matches!(
523            layer.get_field(data, "is_response"),
524            Some(Ok(FieldValue::Bool(true)))
525        ));
526        assert!(layer.get_field(data, "nonexistent").is_none());
527    }
528
529    #[test]
530    fn test_smtp_extract_angle_address() {
531        assert_eq!(
532            extract_angle_address("<user@example.com>"),
533            "user@example.com"
534        );
535        assert_eq!(
536            extract_angle_address("user@example.com"),
537            "user@example.com"
538        );
539        assert_eq!(
540            extract_angle_address(" <user@example.com> "),
541            "user@example.com"
542        );
543    }
544}