flesh out the smtp protocol conversation

Can now receive envelope and data and then discard it all
This commit is contained in:
Wez Furlong
2023-02-11 13:56:34 -07:00
parent 3bd18718ec
commit ead368cc7e
3 changed files with 333 additions and 6 deletions
Generated
+76
View File
@@ -2,12 +2,32 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "aho-corasick"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac"
dependencies = [
"memchr",
]
[[package]]
name = "anyhow"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224afbd727c3d6e4b90103ece64b8d1b67fbb1973b1046c2281eed3f3803f800"
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi 0.1.19",
"libc",
"winapi",
]
[[package]]
name = "autocfg"
version = "1.1.0"
@@ -75,6 +95,23 @@ dependencies = [
"os_str_bytes",
]
[[package]]
name = "colored"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4ffc801dacf156c5854b9df4f425a626539c3a6ef7893cc0c5084a23f0b6c59"
dependencies = [
"atty",
"lazy_static",
"winapi",
]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "errno"
version = "0.2.8"
@@ -102,6 +139,15 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]]
name = "hermit-abi"
version = "0.2.6"
@@ -139,12 +185,30 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "k9"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32ddb58b0079a063218472916af599f2753ccb40942cdaba9d1f3fefccef17a9"
dependencies = [
"anyhow",
"colored",
"diff",
"lazy_static",
"libc",
"proc-macro2",
"regex",
"syn",
"term_size",
]
[[package]]
name = "kumod"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"k9",
"tokio",
"tracing",
"tracing-subscriber",
@@ -338,6 +402,8 @@ version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
@@ -427,6 +493,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "term_size"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4129646ca0ed8f45d09b929036bafad5377103edd06e50bf574b353d2b08d9"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "termcolor"
version = "1.2.0"
+3
View File
@@ -11,3 +11,6 @@ clap = {version="4.1", features=["derive"]}
tokio = {version="1.25", features=["full"]}
tracing = "0.1"
tracing-subscriber = {version="0.3", features=["env-filter"]}
[dev-dependencies]
k9 = "0.11"
+254 -6
View File
@@ -9,6 +9,15 @@ use tracing::instrument;
pub struct SmtpServer<T> {
reader: BufReader<ReadHalf<T>>,
writer: BufWriter<WriteHalf<T>>,
state: Option<TransactionState>,
said_hello: Option<String>,
}
#[derive(Debug)]
struct TransactionState {
sender: EnvelopeAddress,
recipients: Vec<EnvelopeAddress>,
data: Vec<u8>,
}
impl<T: AsyncRead + AsyncWrite + Debug> SmtpServer<T> {
@@ -17,7 +26,12 @@ impl<T: AsyncRead + AsyncWrite + Debug> SmtpServer<T> {
let (reader, writer) = tokio::io::split(socket);
let reader = tokio::io::BufReader::new(reader);
let writer = tokio::io::BufWriter::new(writer);
let mut server = SmtpServer { reader, writer };
let mut server = SmtpServer {
reader,
writer,
state: None,
said_hello: None,
};
server.process().await
}
@@ -37,15 +51,249 @@ impl<T: AsyncRead + AsyncWrite + Debug> SmtpServer<T> {
Ok(())
}
async fn read_line(&mut self) -> anyhow::Result<String> {
let mut line = String::new();
self.reader.read_line(&mut line).await?;
Ok(line)
}
#[instrument]
async fn process(&mut self) -> anyhow::Result<()> {
self.write_message(220, "Greetings from KumoMTA\nW00t!\nYeah!")
self.write_message(220, "mail.example.com KumoMTA\nW00t!\nYeah!")
.await?;
loop {
let mut line = String::new();
self.reader.read_line(&mut line).await?;
self.write_message(420, format!("You said:\n{line}"))
.await?;
let line = self.read_line().await?;
let line = line.trim_end();
match Command::parse(line) {
Err(err) => {
self.write_message(501, format!("Syntax error in command or arguments: {err}"))
.await?;
}
Ok(Command::Quit) => {
self.write_message(221, "So long, and thanks for all the fish!")
.await?;
return Ok(());
}
Ok(Command::Ehlo(domain)) => {
// TODO: we are supposed to report extension commands in our EHLO
// response, but we don't have any yet.
self.write_message(250, format!("mail.example.com Hello {domain}"))
.await?;
self.said_hello.replace(domain);
}
Ok(Command::Helo(domain)) => {
self.write_message(250, format!("Hello {domain}!")).await?;
self.said_hello.replace(domain);
}
Ok(Command::Mail(address)) => {
if self.state.is_some() {
self.write_message(503, "MAIL FROM already issued; you must RSET first")
.await?;
continue;
}
self.write_message(250, format!("OK {address:?}")).await?;
self.state.replace(TransactionState {
sender: address,
recipients: vec![],
data: vec![],
});
}
Ok(Command::Rcpt(address)) => {
if self.state.is_none() {
self.write_message(503, "MAIL FROM must be issued first")
.await?;
continue;
}
self.write_message(250, format!("OK {address:?}")).await?;
self.state
.as_mut()
.expect("checked state above")
.recipients
.push(address);
}
Ok(Command::Data) => {
if self.state.is_none() {
self.write_message(503, "MAIL FROM must be issued first")
.await?;
continue;
}
if self
.state
.as_ref()
.map(|s| s.recipients.is_empty())
.unwrap_or(true)
{
self.write_message(503, "RCPT TO must be issued first")
.await?;
continue;
}
self.write_message(354, "Send body; end with CRLF.CRLF")
.await?;
let mut data = vec![];
loop {
let line = self.read_line().await?;
if line == ".\r\n" {
break;
}
let line = if line.starts_with('.') {
&line[1..]
} else {
&line
};
data.extend_from_slice(line.as_bytes());
}
self.state.as_mut().map(|state| state.data = data);
tracing::trace!(?self.state);
self.write_message(250, "OK TODO: insert queueid here")
.await?;
}
Ok(Command::Rset) => {
self.state.take();
self.write_message(250, "Reset state").await?;
}
Ok(Command::Noop) => {
self.write_message(250, "the goggles do nothing").await?;
}
Ok(Command::Unknown(cmd)) => {
self.write_message(502, format!("Command unrecognized/unimplemented: {cmd}"))
.await?;
}
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum EnvelopeAddress {
Null,
Mailbox { user: String, domain: String },
}
impl EnvelopeAddress {
fn parse(text: &str) -> anyhow::Result<Self> {
if text.is_empty() {
Ok(Self::Null)
} else {
let fields: Vec<&str> = text.split('@').collect();
anyhow::ensure!(fields.len() == 2, "expected user@domain");
// TODO: stronger validation of local part and domain
Ok(Self::Mailbox {
user: fields[0].to_string(),
domain: fields[1].to_string(),
})
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum Command {
Ehlo(String),
Helo(String),
Mail(EnvelopeAddress),
Rcpt(EnvelopeAddress),
Data,
Rset,
Noop,
Quit,
Unknown(String),
}
impl Command {
fn parse(line: &str) -> anyhow::Result<Self> {
fn prefix_match(line: &str, candidate: &str) -> bool {
if line.len() < candidate.len() {
false
} else {
line[..candidate.len()].eq_ignore_ascii_case(candidate)
}
}
fn extract_envelope(line: &str) -> anyhow::Result<(&str, &str)> {
if !line.starts_with('<') {
anyhow::bail!("expected <: {line:?}");
}
let rangle = line
.bytes()
.position(|c| c == b'>')
.ok_or_else(|| anyhow::anyhow!("expected >: {line:?}"))?;
Ok((&line[1..rangle], &line[rangle + 1..]))
}
Ok(if line.eq_ignore_ascii_case("QUIT") {
Self::Quit
} else if line.eq_ignore_ascii_case("DATA") {
Self::Data
} else if line.eq_ignore_ascii_case("RSET") {
Self::Rset
} else if line.eq_ignore_ascii_case("NOOP") {
Self::Noop
} else if prefix_match(line, "EHLO ") {
Self::Ehlo(line[5..].to_string())
} else if prefix_match(line, "HELO ") {
Self::Helo(line[5..].to_string())
} else if prefix_match(line, "MAIL FROM:") {
let (address, _params) = extract_envelope(&line[10..])?;
// TODO: MAIL FROM can accept key/value parameters
Self::Mail(EnvelopeAddress::parse(address)?)
} else if prefix_match(line, "RCPT TO:") {
let (address, _params) = extract_envelope(&line[8..])?;
if address.is_empty() {
anyhow::bail!("Null sender not permitted as a recipient");
}
Self::Rcpt(EnvelopeAddress::parse(address)?)
} else {
Self::Unknown(line.to_string())
})
}
}
#[cfg(test)]
mod test {
use super::*;
use k9::assert_equal;
#[test]
fn command_parser() {
assert_equal!(Command::parse("QUIT").unwrap(), Command::Quit);
assert_equal!(Command::parse("quit").unwrap(), Command::Quit);
assert_equal!(
Command::parse("quite").unwrap(),
Command::Unknown("quite".to_string())
);
assert_equal!(
Command::parse("flibble").unwrap(),
Command::Unknown("flibble".to_string())
);
assert_equal!(
Command::parse("MAIL From:<>").unwrap(),
Command::Mail(EnvelopeAddress::Null)
);
assert_equal!(
Command::parse("MAIL From:<user@example.com>").unwrap(),
Command::Mail(EnvelopeAddress::Mailbox {
user: "user".to_string(),
domain: "example.com".to_string()
})
);
assert_equal!(
Command::parse("rcpt to:<>").unwrap_err().to_string(),
"Null sender not permitted as a recipient"
);
assert_equal!(
Command::parse("rcpt TO:<user@example.com>").unwrap(),
Command::Rcpt(EnvelopeAddress::Mailbox {
user: "user".to_string(),
domain: "example.com".to_string()
})
);
}
}