//! Email DATA phase processor. //! //! Handles dot-unstuffing, end-of-data detection, size enforcement, //! and streaming accumulation of email data. /// Result of processing a chunk of DATA input. #[derive(Debug, Clone, PartialEq)] pub enum DataAction { /// More data needed — continue accumulating. Continue, /// End-of-data detected. The complete message body is ready. Complete, /// Message size limit exceeded. SizeExceeded, } /// Streaming email data accumulator. /// /// Processes incoming bytes from the DATA phase, handling: /// - CRLF line ending normalization /// - Dot-unstuffing (RFC 5321 §4.5.2) /// - End-of-data marker detection (`.`) /// - Size enforcement pub struct DataAccumulator { /// Accumulated message bytes. buffer: Vec, /// Maximum allowed size in bytes. 0 = unlimited. max_size: u64, /// Whether we've detected end-of-data. complete: bool, /// Whether the current position is at the start of a line. at_line_start: bool, /// Partial state for cross-chunk boundary handling. partial: PartialState, } /// Tracks partial sequences that span chunk boundaries. #[derive(Debug, Clone, Copy, PartialEq)] enum PartialState { /// No partial sequence. None, /// Saw `\r`, waiting for `\n`. Cr, /// At line start, saw `.`, waiting to determine dot-stuffing vs end-of-data. Dot, /// At line start, saw `.\r`, waiting for `\n` (end-of-data) or other. DotCr, } impl DataAccumulator { /// Create a new accumulator with the given size limit. pub fn new(max_size: u64) -> Self { Self { buffer: Vec::with_capacity(8192), max_size, complete: false, at_line_start: true, // First byte is at start of first line partial: PartialState::None, } } /// Process a chunk of incoming data. /// /// Returns the action to take: continue, complete, or size exceeded. pub fn process_chunk(&mut self, chunk: &[u8]) -> DataAction { if self.complete { return DataAction::Complete; } for &byte in chunk { match self.partial { PartialState::None => { if self.at_line_start && byte == b'.' { self.partial = PartialState::Dot; } else if byte == b'\r' { self.partial = PartialState::Cr; } else { self.buffer.push(byte); self.at_line_start = false; } } PartialState::Cr => { if byte == b'\n' { self.buffer.extend_from_slice(b"\r\n"); self.at_line_start = true; self.partial = PartialState::None; } else { // Bare CR — emit it and process current byte self.buffer.push(b'\r'); self.at_line_start = false; self.partial = PartialState::None; // Re-process current byte if byte == b'\r' { self.partial = PartialState::Cr; } else { self.buffer.push(byte); } } } PartialState::Dot => { if byte == b'\r' { self.partial = PartialState::DotCr; } else if byte == b'.' { // Dot-unstuffing: \r\n.. → \r\n. // Emit one dot, consume the other self.buffer.push(b'.'); self.at_line_start = false; self.partial = PartialState::None; } else { // Dot at line start but not stuffing or end-of-data self.buffer.push(b'.'); self.buffer.push(byte); self.at_line_start = false; self.partial = PartialState::None; } } PartialState::DotCr => { if byte == b'\n' { // End-of-data: . // Remove the trailing \r\n from the buffer // (it was part of the terminator, not the message) if self.buffer.ends_with(b"\r\n") { let new_len = self.buffer.len() - 2; self.buffer.truncate(new_len); } self.complete = true; return DataAction::Complete; } else { // Not end-of-data — emit .\r and process current byte self.buffer.push(b'.'); self.buffer.push(b'\r'); self.at_line_start = false; self.partial = PartialState::None; // Re-process current byte if byte == b'\r' { self.partial = PartialState::Cr; } else { self.buffer.push(byte); } } } } // Check size limit if self.max_size > 0 && self.buffer.len() as u64 > self.max_size { return DataAction::SizeExceeded; } } DataAction::Continue } /// Consume the accumulator and return the complete message data. /// /// Returns `None` if end-of-data has not been detected. pub fn into_message(self) -> Option> { if !self.complete { return None; } Some(self.buffer) } /// Get a reference to the accumulated data so far. pub fn data(&self) -> &[u8] { &self.buffer } /// Get the current accumulated size. pub fn size(&self) -> usize { self.buffer.len() } /// Whether end-of-data has been detected. pub fn is_complete(&self) -> bool { self.complete } } #[cfg(test)] mod tests { use super::*; #[test] fn test_simple_message() { let mut acc = DataAccumulator::new(0); let data = b"Subject: Test\r\n\r\nHello world\r\n.\r\n"; let action = acc.process_chunk(data); assert_eq!(action, DataAction::Complete); let msg = acc.into_message().unwrap(); assert_eq!(msg, b"Subject: Test\r\n\r\nHello world"); } #[test] fn test_dot_unstuffing() { let mut acc = DataAccumulator::new(0); // A line starting with ".." should become "." let data = b"Line 1\r\n..dot-stuffed\r\n.\r\n"; let action = acc.process_chunk(data); assert_eq!(action, DataAction::Complete); let msg = acc.into_message().unwrap(); assert_eq!(msg, b"Line 1\r\n.dot-stuffed"); } #[test] fn test_multiple_chunks() { let mut acc = DataAccumulator::new(0); assert_eq!(acc.process_chunk(b"Subject: Test\r\n"), DataAction::Continue); assert_eq!(acc.process_chunk(b"\r\nBody line 1\r\n"), DataAction::Continue); assert_eq!(acc.process_chunk(b"Body line 2\r\n.\r\n"), DataAction::Complete); let msg = acc.into_message().unwrap(); assert_eq!(msg, b"Subject: Test\r\n\r\nBody line 1\r\nBody line 2"); } #[test] fn test_end_of_data_spanning_chunks() { let mut acc = DataAccumulator::new(0); assert_eq!(acc.process_chunk(b"Body\r\n"), DataAction::Continue); assert_eq!(acc.process_chunk(b".\r"), DataAction::Continue); assert_eq!(acc.process_chunk(b"\n"), DataAction::Complete); let msg = acc.into_message().unwrap(); assert_eq!(msg, b"Body"); } #[test] fn test_size_limit() { let mut acc = DataAccumulator::new(10); let data = b"This is definitely more than 10 bytes\r\n.\r\n"; let action = acc.process_chunk(data); assert_eq!(action, DataAction::SizeExceeded); } #[test] fn test_not_complete() { let mut acc = DataAccumulator::new(0); acc.process_chunk(b"partial data"); assert!(!acc.is_complete()); assert!(acc.into_message().is_none()); } #[test] fn test_empty_message() { let mut acc = DataAccumulator::new(0); let action = acc.process_chunk(b".\r\n"); assert_eq!(action, DataAction::Complete); let msg = acc.into_message().unwrap(); assert!(msg.is_empty()); } #[test] fn test_dot_not_at_line_start() { let mut acc = DataAccumulator::new(0); let data = b"Hello.World\r\n.\r\n"; let action = acc.process_chunk(data); assert_eq!(action, DataAction::Complete); let msg = acc.into_message().unwrap(); assert_eq!(msg, b"Hello.World"); } #[test] fn test_multiple_dots_in_line() { let mut acc = DataAccumulator::new(0); let data = b"...\r\n.\r\n"; let action = acc.process_chunk(data); assert_eq!(action, DataAction::Complete); // First dot at line start is dot-unstuffed, leaving ".." let msg = acc.into_message().unwrap(); assert_eq!(msg, b".."); } #[test] fn test_crlf_dot_spanning_three_chunks() { let mut acc = DataAccumulator::new(0); assert_eq!(acc.process_chunk(b"Body\r"), DataAction::Continue); assert_eq!(acc.process_chunk(b"\n."), DataAction::Continue); assert_eq!(acc.process_chunk(b"\r\n"), DataAction::Complete); let msg = acc.into_message().unwrap(); assert_eq!(msg, b"Body"); } #[test] fn test_bare_cr() { let mut acc = DataAccumulator::new(0); let data = b"Hello\rWorld\r\n.\r\n"; let action = acc.process_chunk(data); assert_eq!(action, DataAction::Complete); let msg = acc.into_message().unwrap(); assert_eq!(msg, b"Hello\rWorld"); } }