use crate::ReplyCode;
#[must_use]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reply {
code: ReplyCode,
text: Vec<String>,
folded: String,
}
impl serde::Serialize for Reply {
#[inline]
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.fold())
}
}
impl<'de> serde::Deserialize<'de> for Reply {
#[inline]
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct ReplyVisitor;
impl<'de> serde::de::Visitor<'de> for ReplyVisitor {
type Value = Reply;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("[...]")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
<Reply as std::str::FromStr>::from_str(v).map_err(serde::de::Error::custom)
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
#[derive(serde::Deserialize)]
#[serde(field_identifier, rename_all = "lowercase")]
enum Field {
Code,
Enhanced,
Text,
}
let mut text: Option<String> = None;
let mut code = None;
let mut enhanced = None;
while let Some(key) = map.next_key()? {
match key {
Field::Code => {
if code.is_some() {
return Err(serde::de::Error::duplicate_field("code"));
}
code = Some(map.next_value()?);
}
Field::Text => {
if text.is_some() {
return Err(serde::de::Error::duplicate_field("text"));
}
text = Some(map.next_value()?);
}
Field::Enhanced => {
if enhanced.is_some() {
return Err(serde::de::Error::duplicate_field("enhanced"));
}
enhanced = Some(map.next_value()?);
}
}
}
let code = code.ok_or_else(|| serde::de::Error::missing_field("code"))?;
let reply = Reply {
code: enhanced.map_or(ReplyCode::Code { code }, |enhanced| {
ReplyCode::Enhanced { code, enhanced }
}),
text: vec![text.ok_or_else(|| serde::de::Error::missing_field("text"))?],
folded: String::new(),
};
Ok(Reply {
folded: reply.fold(),
..reply
})
}
}
deserializer.deserialize_any(ReplyVisitor)
}
}
impl Reply {
#[must_use]
#[inline]
pub const fn code(&self) -> &ReplyCode {
&self.code
}
fn fold(&self) -> String {
let prefix = self.code.to_string();
let mut output = self
.text
.iter()
.map(|line| format!("{prefix} {line}"))
.collect::<Vec<_>>();
let len = output.len();
for i in output.iter_mut().take(len.saturating_sub(1)) {
i.replace_range(3..4, "-");
}
if let Some(s) = output.get_mut(len.saturating_sub(1)) {
s.replace_range(3..4, " ");
}
output
.into_iter()
.flat_map(|mut l| {
l.push_str("\r\n");
l.chars().collect::<Vec<_>>()
})
.collect::<String>()
}
#[inline]
pub fn lines(&self) -> impl Iterator<Item = &String> {
self.text.iter()
}
#[inline]
pub fn extended(mut self, other: &Self) -> Self {
self.text.extend(other.text.iter().cloned());
let reply = Self {
code: match &other.code {
ReplyCode::Code { code } => ReplyCode::Code { code: *code },
ReplyCode::Enhanced { code, enhanced } => ReplyCode::Enhanced {
code: *code,
enhanced: enhanced.to_string(),
},
},
text: self.text,
folded: String::new(),
};
Self {
folded: reply.fold(),
..reply
}
}
}
impl std::str::FromStr for Reply {
type Err = anyhow::Error;
#[inline]
#[allow(clippy::panic_in_result_fn)]
fn from_str(s: &str) -> Result<Self, Self::Err> {
let x = s
.split("\r\n")
.filter(|s| !s.is_empty())
.map(ReplyCode::from_str);
let mut first_code = None;
let mut text = vec![];
for x in x {
let (new_code, mut line) = x?;
match (&first_code, new_code) {
(Some(ReplyCode::Code { code: first }), ReplyCode::Code { code: new })
if *first == new => {}
(
Some(ReplyCode::Enhanced {
code: first,
enhanced: first_enhanced,
}),
ReplyCode::Enhanced {
code: new,
enhanced: new_enhanced,
},
) if *first == new && *first_enhanced == new_enhanced => (),
(Some(_), _) => anyhow::bail!("Reply codes are not consistent"),
(None, anything) => first_code = Some(anything),
}
if !line.is_empty() {
let c = line.remove(0);
assert!(" -".contains(c));
}
text.push(line);
}
let reply = Self {
code: first_code.ok_or_else(|| anyhow::anyhow!("No reply code found"))?,
text,
folded: String::new(),
};
Ok(Self {
folded: reply.fold(),
..reply
})
}
}
impl std::fmt::Display for Reply {
#[inline]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.folded)
}
}
impl AsRef<str> for Reply {
#[inline]
fn as_ref(&self) -> &str {
&self.folded
}
}
#[cfg(test)]
mod tests {
use crate::{Reply, ReplyCode};
#[rstest::rstest]
#[case(
&Reply {
code: ReplyCode::Code { code: 501 },
text: vec![String::new()],
folded: "501 \r\n".to_owned(),
}
)]
#[case(
&Reply {
code: ReplyCode::Code { code: 220,},
text: vec!["this is a custom code.".to_owned()],
folded: "220 this is a custom code.\r\n".to_owned(),
}
)]
#[case(
&Reply {
code: ReplyCode::Enhanced { code: 504, enhanced: "5.5.4".to_owned() },
text: vec![String::new()],
folded: "504 5.5.4 \r\n".to_owned(),
}
)]
#[case(
&Reply {
code: ReplyCode::Enhanced { code: 451, enhanced: "5.7.3".to_owned() },
text: vec!["STARTTLS is required to send mail".to_owned()],
folded: "451 5.7.3 STARTTLS is required to send mail\r\n".to_owned(),
}
)]
#[case(
&Reply {
code: ReplyCode::Code { code: 250, },
text: vec![
"mydomain.tld".to_owned(),
"PIPELINING".to_owned(),
"8BITMIME".to_owned(),
"AUTH PLAIN LOGIN".to_owned(),
"XCLIENT NAME HELO".to_owned(),
"XFORWARD NAME ADDR PROTO HELO".to_owned(),
"ENHANCEDSTATUSCODES".to_owned(),
"DSN".to_owned(),
String::new(),
],
folded: concat!(
"250-mydomain.tld\r\n",
"250-PIPELINING\r\n",
"250-8BITMIME\r\n",
"250-AUTH PLAIN LOGIN\r\n",
"250-XCLIENT NAME HELO\r\n",
"250-XFORWARD NAME ADDR PROTO HELO\r\n",
"250-ENHANCEDSTATUSCODES\r\n",
"250-DSN\r\n",
"250 \r\n",
).to_owned(),
}
)]
#[case(
&Reply {
code: ReplyCode::Enhanced {
code: 220,
enhanced: "2.0.0".to_owned(),
},
text: vec![
"this is a long message, a very very long message ...".to_owned(),
" carriage return will be properly added automatically.".to_owned(),
],
folded: concat!(
"220-2.0.0 this is a long message, a very very long message ...\r\n",
"220 2.0.0 carriage return will be properly added automatically.\r\n",
).to_owned(),
}
)]
#[case(
&Reply {
code: ReplyCode::Enhanced {
code: 220,
enhanced: "2.0.0".to_owned(),
},
text: vec![
"this is a long message, a very very long message ... carriage return".to_owned(),
" will be properly added automatically. Made by vSMTP mail transfer a".to_owned(),
"gent\nCopyright (C) 2022 viridIT SAS".to_owned(),
],
folded: concat!(
"220-2.0.0 this is a long message, a very very long message ... carriage return\r\n",
"220-2.0.0 will be properly added automatically. Made by vSMTP mail transfer a\r\n",
"220 2.0.0 gent\nCopyright (C) 2022 viridIT SAS\r\n",
).to_owned(),
}
)]
fn parse_reply(#[case] expected: &Reply) {
let input: &str = expected.as_ref();
for i in input.split("\r\n") {
assert!(i.len() <= 78);
}
let output = input.parse::<Reply>().unwrap();
pretty_assertions::assert_eq!(output, *expected);
let fold = output.fold();
pretty_assertions::assert_eq!(input, fold);
}
}