stackforge_core/layer/smtp/
mod.rs1pub mod builder;
45pub use builder::SmtpBuilder;
46
47use crate::layer::field::{FieldError, FieldValue};
48use crate::layer::{Layer, LayerIndex, LayerKind};
49
50pub const SMTP_MIN_HEADER_LEN: usize = 4;
52
53pub const SMTP_PORT: u16 = 25;
55
56pub const SMTP_SUBMISSION_PORT: u16 = 587;
58
59pub const SMTPS_PORT: u16 = 465;
61
62pub 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
93pub 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
116pub 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#[must_use]
135pub fn is_smtp_payload(buf: &[u8]) -> bool {
136 if buf.len() < 3 {
137 return false;
138 }
139 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 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#[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 #[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 #[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 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 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 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 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 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..]; Ok(extract_angle_address(addr_part))
295 }
296
297 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..]; Ok(extract_angle_address(addr_part))
315 }
316
317 #[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
340fn 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#[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#[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}