mirror of
https://github.com/mailscope/kumomta.git
synced 2026-10-28 09:16:15 +00:00
flesh out the smtp protocol conversation
Can now receive envelope and data and then discard it all
This commit is contained in:
Generated
+76
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user