postcrate-core 0.1.1

Embeddable SMTP capture engine: server, multi-mailbox lifecycle, chaos simulation, SQLite persistence, HTTP API.
Documentation
//! SMTP reply construction. Each variant maps to a numeric reply code +
//! one or more text lines. Multi-line replies use the `code-text` /
//! `code text` continuation form from RFC 5321 ยง4.2.1.

use std::borrow::Cow;

use tokio::io::{AsyncWrite, AsyncWriteExt};

use crate::error::Result;
use crate::smtp::codec::TranscriptSink;

#[derive(Debug, Clone)]
pub struct SmtpReply {
    pub code: u16,
    pub lines: Vec<Cow<'static, str>>,
}

impl SmtpReply {
    pub fn new(code: u16, line: impl Into<Cow<'static, str>>) -> Self {
        Self {
            code,
            lines: vec![line.into()],
        }
    }

    pub fn multi(code: u16, lines: Vec<Cow<'static, str>>) -> Self {
        Self { code, lines }
    }

    pub fn greet(host: &str) -> Self {
        SmtpReply::new(220, format!("{host} ESMTP Postcrate ready"))
    }

    pub fn ok() -> Self {
        SmtpReply::new(250, "OK")
    }

    pub fn ok_msg(msg: impl Into<Cow<'static, str>>) -> Self {
        SmtpReply::new(250, msg)
    }

    pub fn bye() -> Self {
        SmtpReply::new(221, "Bye")
    }

    pub fn start_mail_input() -> Self {
        SmtpReply::new(354, "End data with <CR><LF>.<CR><LF>")
    }

    pub fn bad_sequence() -> Self {
        SmtpReply::new(503, "Bad sequence of commands")
    }

    pub fn syntax_error() -> Self {
        SmtpReply::new(500, "Syntax error, command unrecognized")
    }

    pub fn command_not_implemented() -> Self {
        SmtpReply::new(502, "Command not implemented")
    }

    pub fn line_too_long() -> Self {
        SmtpReply::new(500, "Line too long")
    }

    pub fn transaction_failed() -> Self {
        SmtpReply::new(554, "Transaction failed")
    }

    pub fn size_exceeded() -> Self {
        SmtpReply::new(552, "Message size exceeds fixed maximum")
    }

    pub fn vrfy_cannot() -> Self {
        SmtpReply::new(252, "Cannot VRFY user; try RCPT")
    }

    pub fn help_lines() -> Self {
        SmtpReply::multi(
            214,
            vec![
                "Postcrate supports the following commands:".into(),
                "HELO EHLO MAIL RCPT DATA RSET NOOP QUIT VRFY HELP".into(),
            ],
        )
    }

    pub fn custom(code: u16, msg: impl Into<Cow<'static, str>>) -> Self {
        SmtpReply::new(code, msg)
    }

    pub fn start_tls_ready() -> Self {
        SmtpReply::new(220, "Ready to start TLS")
    }

    pub fn tls_not_available() -> Self {
        SmtpReply::new(454, "TLS not available")
    }

    pub fn tls_already_active() -> Self {
        SmtpReply::new(503, "TLS already active")
    }

    pub fn auth_ok() -> Self {
        SmtpReply::new(235, "Authentication successful")
    }

    pub fn auth_continue(prompt_b64: &str) -> Self {
        SmtpReply::new(334, prompt_b64.to_string())
    }

    pub fn auth_failed() -> Self {
        SmtpReply::new(535, "Authentication failed")
    }

    pub fn auth_unsupported() -> Self {
        SmtpReply::new(504, "Unsupported authentication mechanism")
    }
}

/// Writer wrapper that serializes a `SmtpReply` to the wire.
pub struct ReplyWriter<W> {
    inner: W,
    transcript: Option<TranscriptSink>,
}

impl<W: AsyncWrite + Unpin> ReplyWriter<W> {
    pub fn new(inner: W) -> Self {
        Self {
            inner,
            transcript: None,
        }
    }

    /// Attach a transcript sink โ€” every send appends one `< ...` line
    /// per reply line in the same shape the wire would see.
    pub fn with_transcript(mut self, sink: Option<TranscriptSink>) -> Self {
        self.transcript = sink;
        self
    }

    pub fn into_inner(self) -> W {
        self.inner
    }

    pub async fn send(&mut self, reply: &SmtpReply) -> Result<()> {
        let n = reply.lines.len();
        if n == 0 {
            // Defensive โ€” never write nothing.
            let line = format!("{} \r\n", reply.code);
            self.inner.write_all(line.as_bytes()).await?;
            self.inner.flush().await?;
            if let Some(t) = &self.transcript {
                t.lock().push(format!("< {}", reply.code));
            }
            return Ok(());
        }
        for (i, l) in reply.lines.iter().enumerate() {
            let sep = if i + 1 == n { ' ' } else { '-' };
            let line = format!("{}{sep}{}\r\n", reply.code, l);
            self.inner.write_all(line.as_bytes()).await?;
            if let Some(t) = &self.transcript {
                t.lock().push(format!("< {}{sep}{}", reply.code, l));
            }
        }
        self.inner.flush().await?;
        Ok(())
    }

    /// Write a literal byte sequence โ€” used by chaos `malformed` injection.
    pub async fn send_raw(&mut self, bytes: &[u8]) -> Result<()> {
        self.inner.write_all(bytes).await?;
        self.inner.flush().await?;
        if let Some(t) = &self.transcript {
            t.lock().push(format!(
                "< [raw {} bytes] {}",
                bytes.len(),
                String::from_utf8_lossy(bytes).trim_end()
            ));
        }
        Ok(())
    }
}