feat(mailer-smtp): implement in-process SMTP server and management IPC integration
This commit is contained in:
219
rust/crates/mailer-smtp/src/state.rs
Normal file
219
rust/crates/mailer-smtp/src/state.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
//! SMTP protocol state machine.
|
||||
//!
|
||||
//! Defines valid states and transitions for an SMTP session.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// SMTP session states following RFC 5321.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SmtpState {
|
||||
/// Initial state — waiting for server greeting.
|
||||
Connected,
|
||||
/// After successful EHLO/HELO.
|
||||
Greeted,
|
||||
/// After MAIL FROM accepted.
|
||||
MailFrom,
|
||||
/// After at least one RCPT TO accepted.
|
||||
RcptTo,
|
||||
/// In DATA mode — accumulating message body.
|
||||
Data,
|
||||
/// Transaction completed — can start a new one or QUIT.
|
||||
Finished,
|
||||
}
|
||||
|
||||
/// State transition errors.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum TransitionError {
|
||||
#[error("cannot {action} in state {state:?}")]
|
||||
InvalidTransition {
|
||||
state: SmtpState,
|
||||
action: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
impl SmtpState {
|
||||
/// Check whether EHLO/HELO is valid in the current state.
|
||||
/// EHLO/HELO can be issued at any time to reset the session.
|
||||
pub fn can_ehlo(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Check whether MAIL FROM is valid in the current state.
|
||||
pub fn can_mail_from(&self) -> bool {
|
||||
matches!(self, SmtpState::Greeted | SmtpState::Finished)
|
||||
}
|
||||
|
||||
/// Check whether RCPT TO is valid in the current state.
|
||||
pub fn can_rcpt_to(&self) -> bool {
|
||||
matches!(self, SmtpState::MailFrom | SmtpState::RcptTo)
|
||||
}
|
||||
|
||||
/// Check whether DATA is valid in the current state.
|
||||
pub fn can_data(&self) -> bool {
|
||||
matches!(self, SmtpState::RcptTo)
|
||||
}
|
||||
|
||||
/// Check whether STARTTLS is valid in the current state.
|
||||
/// Only before a transaction starts.
|
||||
pub fn can_starttls(&self) -> bool {
|
||||
matches!(self, SmtpState::Connected | SmtpState::Greeted | SmtpState::Finished)
|
||||
}
|
||||
|
||||
/// Check whether AUTH is valid in the current state.
|
||||
/// Only after EHLO and before a transaction starts.
|
||||
pub fn can_auth(&self) -> bool {
|
||||
matches!(self, SmtpState::Greeted | SmtpState::Finished)
|
||||
}
|
||||
|
||||
/// Transition to Greeted state (after EHLO/HELO).
|
||||
pub fn transition_ehlo(&self) -> Result<SmtpState, TransitionError> {
|
||||
// EHLO is always valid — it resets the session.
|
||||
Ok(SmtpState::Greeted)
|
||||
}
|
||||
|
||||
/// Transition to MailFrom state (after MAIL FROM accepted).
|
||||
pub fn transition_mail_from(&self) -> Result<SmtpState, TransitionError> {
|
||||
if self.can_mail_from() {
|
||||
Ok(SmtpState::MailFrom)
|
||||
} else {
|
||||
Err(TransitionError::InvalidTransition {
|
||||
state: *self,
|
||||
action: "MAIL FROM",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Transition to RcptTo state (after RCPT TO accepted).
|
||||
pub fn transition_rcpt_to(&self) -> Result<SmtpState, TransitionError> {
|
||||
if self.can_rcpt_to() {
|
||||
Ok(SmtpState::RcptTo)
|
||||
} else {
|
||||
Err(TransitionError::InvalidTransition {
|
||||
state: *self,
|
||||
action: "RCPT TO",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Transition to Data state (after DATA command accepted).
|
||||
pub fn transition_data(&self) -> Result<SmtpState, TransitionError> {
|
||||
if self.can_data() {
|
||||
Ok(SmtpState::Data)
|
||||
} else {
|
||||
Err(TransitionError::InvalidTransition {
|
||||
state: *self,
|
||||
action: "DATA",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Transition to Finished state (after end-of-data).
|
||||
pub fn transition_finished(&self) -> Result<SmtpState, TransitionError> {
|
||||
if *self == SmtpState::Data {
|
||||
Ok(SmtpState::Finished)
|
||||
} else {
|
||||
Err(TransitionError::InvalidTransition {
|
||||
state: *self,
|
||||
action: "finish DATA",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset to Greeted state (after RSET command).
|
||||
pub fn transition_rset(&self) -> Result<SmtpState, TransitionError> {
|
||||
match self {
|
||||
SmtpState::Connected => Err(TransitionError::InvalidTransition {
|
||||
state: *self,
|
||||
action: "RSET",
|
||||
}),
|
||||
_ => Ok(SmtpState::Greeted),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_initial_state() {
|
||||
let state = SmtpState::Connected;
|
||||
assert!(!state.can_mail_from());
|
||||
assert!(!state.can_rcpt_to());
|
||||
assert!(!state.can_data());
|
||||
assert!(state.can_starttls());
|
||||
assert!(state.can_ehlo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ehlo_always_valid() {
|
||||
for state in [
|
||||
SmtpState::Connected,
|
||||
SmtpState::Greeted,
|
||||
SmtpState::MailFrom,
|
||||
SmtpState::RcptTo,
|
||||
SmtpState::Data,
|
||||
SmtpState::Finished,
|
||||
] {
|
||||
assert!(state.can_ehlo());
|
||||
assert!(state.transition_ehlo().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normal_flow() {
|
||||
let state = SmtpState::Connected;
|
||||
let state = state.transition_ehlo().unwrap();
|
||||
assert_eq!(state, SmtpState::Greeted);
|
||||
|
||||
let state = state.transition_mail_from().unwrap();
|
||||
assert_eq!(state, SmtpState::MailFrom);
|
||||
|
||||
let state = state.transition_rcpt_to().unwrap();
|
||||
assert_eq!(state, SmtpState::RcptTo);
|
||||
|
||||
// Multiple RCPT TO
|
||||
let state = state.transition_rcpt_to().unwrap();
|
||||
assert_eq!(state, SmtpState::RcptTo);
|
||||
|
||||
let state = state.transition_data().unwrap();
|
||||
assert_eq!(state, SmtpState::Data);
|
||||
|
||||
let state = state.transition_finished().unwrap();
|
||||
assert_eq!(state, SmtpState::Finished);
|
||||
|
||||
// New transaction
|
||||
let state = state.transition_mail_from().unwrap();
|
||||
assert_eq!(state, SmtpState::MailFrom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_transitions() {
|
||||
assert!(SmtpState::Connected.transition_mail_from().is_err());
|
||||
assert!(SmtpState::Connected.transition_rcpt_to().is_err());
|
||||
assert!(SmtpState::Connected.transition_data().is_err());
|
||||
assert!(SmtpState::Greeted.transition_rcpt_to().is_err());
|
||||
assert!(SmtpState::Greeted.transition_data().is_err());
|
||||
assert!(SmtpState::MailFrom.transition_data().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rset() {
|
||||
let state = SmtpState::RcptTo;
|
||||
let state = state.transition_rset().unwrap();
|
||||
assert_eq!(state, SmtpState::Greeted);
|
||||
|
||||
// RSET from Connected is invalid (no EHLO yet)
|
||||
assert!(SmtpState::Connected.transition_rset().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_starttls_validity() {
|
||||
assert!(SmtpState::Connected.can_starttls());
|
||||
assert!(SmtpState::Greeted.can_starttls());
|
||||
assert!(!SmtpState::MailFrom.can_starttls());
|
||||
assert!(!SmtpState::RcptTo.can_starttls());
|
||||
assert!(!SmtpState::Data.can_starttls());
|
||||
assert!(SmtpState::Finished.can_starttls());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user