Files
Stalwart/tests/src/directory/smtp.rs
2023-12-14 12:35:25 +01:00

343 lines
11 KiB
Rust

/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of Stalwart Mail Server.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* in the LICENSE file at the top-level directory of this distribution.
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use std::sync::Arc;
use directory::{DirectoryError, QueryBy};
use mail_parser::decoders::base64::base64_decode;
use mail_send::Credentials;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
sync::watch,
};
use tokio_rustls::TlsAcceptor;
use utils::listener::limiter::{ConcurrencyLimiter, InFlight};
use crate::directory::{parse_config, Item, LookupResult};
use super::dummy_tls_acceptor;
#[tokio::test]
async fn smtp_directory() {
// Spawn mock LMTP server
let shutdown = spawn_mock_lmtp_server(5);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// Obtain directory handle
let mut config = parse_config().await;
let handle = config.directories.directories.remove("smtp").unwrap();
// Basic lookup
let tests = vec![
(
Item::IsAccount("john-ok@domain".to_string()),
LookupResult::True,
),
(
Item::IsAccount("john-bad@domain".to_string()),
LookupResult::False,
),
(
Item::Verify("john-ok@domain".to_string()),
LookupResult::Values(vec!["john-ok@domain".to_string()]),
),
(
Item::Verify("doesnot@exist.org".to_string()),
LookupResult::False,
),
(
Item::Expand("sales-ok,item1,item2,item3".to_string()),
LookupResult::Values(vec![
"sales-ok".to_string(),
"item1".to_string(),
"item2".to_string(),
"item3".to_string(),
]),
),
(Item::Expand("other".to_string()), LookupResult::False),
(
Item::Authenticate(Credentials::Plain {
username: "john".to_string(),
secret: "ok".to_string(),
}),
LookupResult::True,
),
(
Item::Authenticate(Credentials::Plain {
username: "john".to_string(),
secret: "bad".to_string(),
}),
LookupResult::False,
),
];
for (item, expected) in &tests {
let result: LookupResult = match item {
Item::IsAccount(v) => handle.rcpt(v).await.unwrap().into(),
Item::Authenticate(v) => handle
.query(QueryBy::credentials(v))
.await
.unwrap()
.is_some()
.into(),
Item::Verify(v) => match handle.vrfy(v).await {
Ok(v) => v.into(),
Err(DirectoryError::Unsupported) => LookupResult::False,
Err(e) => panic!("Unexpected error: {e:?}"),
},
Item::Expand(v) => match handle.expn(v).await {
Ok(v) => v.into(),
Err(DirectoryError::Unsupported) => LookupResult::False,
Err(e) => panic!("Unexpected error: {e:?}"),
},
};
assert_eq!(&result, expected);
}
// Concurrent requests
let mut requests = Vec::new();
for n in 0..100 {
let (item, expected) = &tests[n % tests.len()];
let item = item.append(n);
let item_clone = item.clone();
let handle = handle.clone();
requests.push((
tokio::spawn(async move {
let result: LookupResult = match &item {
Item::IsAccount(v) => handle.rcpt(v).await.unwrap().into(),
Item::Authenticate(v) => handle
.query(QueryBy::credentials(v))
.await
.unwrap()
.is_some()
.into(),
Item::Verify(v) => match handle.vrfy(v).await {
Ok(v) => v.into(),
Err(DirectoryError::Unsupported) => LookupResult::False,
Err(e) => panic!("Unexpected error: {e:?}"),
},
Item::Expand(v) => match handle.expn(v).await {
Ok(v) => v.into(),
Err(DirectoryError::Unsupported) => LookupResult::False,
Err(e) => panic!("Unexpected error: {e:?}"),
},
};
result
}),
item_clone,
expected.append(n),
));
}
for (result, item, expected_result) in requests {
assert_eq!(
result.await.unwrap(),
expected_result,
"Failed for {item:?}"
);
}
// Shutdown
shutdown.send(false).ok();
// Verify that caching works
TcpStream::connect("127.0.0.1:9199").await.unwrap_err();
let mut requests = Vec::new();
for n in 0..100 {
let (item, expected) = &tests[n % tests.len()];
if matches!(item, Item::IsAccount(_)) {
let item = item.append(n);
let item_clone = item.clone();
let handle = handle.clone();
requests.push((
tokio::spawn(async move {
let result: LookupResult = match &item {
Item::IsAccount(v) => handle.rcpt(v).await.unwrap().into(),
_ => unreachable!(),
};
result
}),
item_clone,
expected.append(n),
));
}
}
assert!(!requests.is_empty());
for (result, item, expected_result) in requests {
assert_eq!(
result.await.unwrap(),
expected_result,
"Failed for {item:?}"
);
}
}
pub fn spawn_mock_lmtp_server(max_concurrency: u64) -> watch::Sender<bool> {
let (tx, rx) = watch::channel(true);
tokio::spawn(async move {
let listener = TcpListener::bind("127.0.0.1:9199")
.await
.unwrap_or_else(|e| {
panic!("Failed to bind mock SMTP server to 127.0.0.1:9199: {e}");
});
let acceptor = dummy_tls_acceptor();
let limited = ConcurrencyLimiter::new(max_concurrency);
let mut rx_ = rx.clone();
loop {
tokio::select! {
stream = listener.accept() => {
match stream {
Ok((stream, _)) => {
let acceptor = acceptor.clone();
let in_flight = limited.is_allowed();
tokio::spawn(accept_smtp(stream, rx.clone(), acceptor, in_flight));
}
Err(err) => {
panic!("Something went wrong: {err}" );
}
}
},
_ = rx_.changed() => {
break;
}
};
}
});
tx
}
async fn accept_smtp(
stream: TcpStream,
mut rx: watch::Receiver<bool>,
acceptor: Arc<TlsAcceptor>,
in_flight: Option<InFlight>,
) {
let mut stream = acceptor.accept(stream).await.unwrap();
stream
.write_all(b"220 [127.0.0.1] Clueless host service ready\r\n")
.await
.unwrap();
if in_flight.is_none() {
eprintln!("WARNING: Concurrency exceeded!");
}
let mut buf_u8 = vec![0u8; 1024];
loop {
let br = tokio::select! {
br = stream.read(&mut buf_u8) => {
match br {
Ok(br) => {
br
}
Err(_) => {
break;
}
}
},
_ = rx.changed() => {
break;
}
};
let buf = std::str::from_utf8(&buf_u8[0..br]).unwrap();
let response = if buf.starts_with("LHLO") {
"250-mx.foobar.org\r\n250 AUTH PLAIN\r\n".to_string()
} else if buf.starts_with("MAIL FROM") {
if buf.contains("<>") || buf.contains("ok@") {
"250 OK\r\n".to_string()
} else {
"552-I do not\r\n552 like that MAIL FROM.\r\n".to_string()
}
} else if buf.starts_with("RCPT TO") {
if buf.contains("ok") {
"250 OK\r\n".to_string()
} else {
"550-I refuse to\r\n550 accept that recipient.\r\n".to_string()
}
} else if buf.starts_with("VRFY") {
if buf.contains("ok") {
format!("250 {}\r\n", buf.split_once(' ').unwrap().1)
} else {
"550-I refuse to\r\n550 verify that recipient.\r\n".to_string()
}
} else if buf.starts_with("EXPN") {
if buf.contains("ok") {
let parts = buf
.split_once(' ')
.unwrap()
.1
.split(',')
.filter_map(|s| {
if !s.is_empty() {
s.to_string().into()
} else {
None
}
})
.collect::<Vec<_>>();
let mut buf = String::with_capacity(16);
for (pos, part) in parts.iter().enumerate() {
buf.push_str("250");
buf.push(if pos == parts.len() - 1 { ' ' } else { '-' });
buf.push_str(part);
buf.push_str("\r\n");
}
buf
} else {
"550-I refuse to\r\n550 accept that recipient.\r\n".to_string()
}
} else if buf.starts_with("AUTH PLAIN") {
let buf = base64_decode(buf.rsplit_once(' ').unwrap().1.as_bytes()).unwrap();
if String::from_utf8_lossy(&buf).contains("ok") {
"235 Great success!\r\n".to_string()
} else {
"535 No soup for you\r\n".to_string()
}
} else if buf.starts_with("NOOP") {
"250 Siesta time\r\n".to_string()
} else if buf.starts_with("QUIT") {
"250 Arrivederci!\r\n".to_string()
} else if buf.starts_with("RSET") {
"250 Your wish is my command.\r\n".to_string()
} else {
panic!("Unknown command: {}", buf.trim());
};
//print!("<- {}", response);
stream.write_all(response.as_bytes()).await.unwrap();
if buf.contains("bye") || buf.starts_with("QUIT") {
return;
}
}
}