use std::fmt::Display;
use std::str::FromStr;
use crate::email::EmailAddressParseError;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct MessageId(String);
impl MessageId {
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl Display for MessageId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for MessageId {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
"MessageId".into()
}
fn schema_id() -> std::borrow::Cow<'static, str> {
concat!(module_path!(), "::MessageId").into()
}
fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"description": "RFC 5322 Message-ID field value, including angle brackets"
})
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum MessageIdParseError {
#[error("Message-ID must be enclosed in angle brackets")]
MissingBrackets,
#[error("Message-ID contains whitespace")]
ContainsWhitespace,
#[error("Message-ID is missing the local part")]
MissingLocal,
#[error("Message-ID is missing the domain part")]
MissingDomain,
#[error("Message-ID local-part or domain is malformed")]
#[non_exhaustive]
InvalidContent {
#[source]
source: EmailAddressParseError,
},
#[error(
"Message-ID `id-left` uses the obsolete quoted-string form; the kernel commits to RFC 5322 dot-atom-text only"
)]
ObsoleteIdLeftForm,
}
impl PartialEq for MessageIdParseError {
fn eq(&self, other: &Self) -> bool {
matches!(
(self, other),
(Self::MissingBrackets, Self::MissingBrackets)
| (Self::ContainsWhitespace, Self::ContainsWhitespace)
| (Self::MissingLocal, Self::MissingLocal)
| (Self::MissingDomain, Self::MissingDomain)
| (Self::InvalidContent { .. }, Self::InvalidContent { .. })
| (Self::ObsoleteIdLeftForm, Self::ObsoleteIdLeftForm)
)
}
}
impl Eq for MessageIdParseError {}
impl FromStr for MessageId {
type Err = MessageIdParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let value = s.trim();
if !(value.starts_with('<') && value.ends_with('>') && value.len() >= 2) {
return Err(MessageIdParseError::MissingBrackets);
}
if value.chars().any(char::is_whitespace) {
return Err(MessageIdParseError::ContainsWhitespace);
}
let inner = &value[1..value.len() - 1];
if inner.starts_with('"') {
return Err(MessageIdParseError::ObsoleteIdLeftForm);
}
if let Some((local, domain)) = inner.split_once('@') {
if local.is_empty() {
return Err(MessageIdParseError::MissingLocal);
}
if domain.is_empty() {
return Err(MessageIdParseError::MissingDomain);
}
} else {
return Err(MessageIdParseError::MissingDomain);
}
let parsed = addr_spec::AddrSpec::from_str(inner).map_err(|error| {
MessageIdParseError::InvalidContent {
source: EmailAddressParseError::from(error),
}
})?;
let is_literal = parsed.is_literal();
let (local, domain) = parsed.into_serialized_parts();
let normalized = if is_literal {
format!("<{local}@{domain}>")
} else {
format!("<{local}@{}>", domain.to_ascii_lowercase())
};
Ok(Self(normalized))
}
}
impl TryFrom<&str> for MessageId {
type Error = MessageIdParseError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::from_str(value)
}
}
impl From<MessageId> for String {
fn from(value: MessageId) -> Self {
value.0
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for MessageId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for MessageId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
#[cfg(feature = "arbitrary")]
impl<'a> arbitrary::Arbitrary<'a> for MessageId {
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
let local = u64::arbitrary(u)?;
let domain = u32::arbitrary(u)?;
Ok(Self(format!("<{local}@{domain}.test>")))
}
}
#[cfg(test)]
mod tests {
use super::{MessageId, MessageIdParseError};
#[test]
fn message_id_from_str_accepts_valid_values() {
let parsed = "<abc@example.com>".parse::<MessageId>();
assert!(parsed.is_ok(), "expected valid message id");
}
#[test]
fn message_id_from_str_rejects_missing_brackets() {
let parsed = "abc@example.com".parse::<MessageId>();
assert_eq!(parsed.unwrap_err(), MessageIdParseError::MissingBrackets);
}
#[test]
fn message_id_from_str_rejects_missing_at() {
let parsed = "<abc>".parse::<MessageId>();
assert_eq!(parsed.unwrap_err(), MessageIdParseError::MissingDomain);
}
#[test]
fn message_id_from_str_rejects_whitespace() {
let parsed = "<abc @example.com>".parse::<MessageId>();
assert_eq!(parsed.unwrap_err(), MessageIdParseError::ContainsWhitespace);
}
#[test]
fn message_id_from_str_rejects_empty_local_part() {
let parsed = "<@example.com>".parse::<MessageId>();
assert_eq!(parsed.unwrap_err(), MessageIdParseError::MissingLocal);
}
#[test]
fn message_id_from_str_rejects_empty_domain() {
let parsed = "<abc@>".parse::<MessageId>();
assert_eq!(parsed.unwrap_err(), MessageIdParseError::MissingDomain);
}
#[test]
fn message_id_from_str_rejects_dot_atom_violations() {
for input in [
"<.bad@example.com>",
"<a..b@example.com>",
"<a.@example.com>",
] {
let parsed = input.parse::<MessageId>();
assert!(
matches!(parsed, Err(MessageIdParseError::InvalidContent { .. })),
"expected InvalidContent for {input}, got {parsed:?}"
);
}
}
#[test]
fn message_id_from_str_rejects_quoted_string_id_left() {
let parsed = "<\"weird\"@example.com>".parse::<MessageId>();
assert_eq!(parsed.unwrap_err(), MessageIdParseError::ObsoleteIdLeftForm);
}
#[test]
fn message_id_from_str_rejects_quoted_at_in_local_part() {
let parsed = "<\"a@b\"@example.com>".parse::<MessageId>();
assert_eq!(parsed.unwrap_err(), MessageIdParseError::ObsoleteIdLeftForm);
}
#[test]
fn message_id_from_str_case_folds_domain() {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let upper = "<foo@Example.COM>"
.parse::<MessageId>()
.expect("upper-case domain should parse");
let lower = "<foo@example.com>"
.parse::<MessageId>()
.expect("lower-case domain should parse");
assert_eq!(upper, lower);
assert_eq!(upper.as_str(), "<foo@example.com>");
let mut h_upper = DefaultHasher::new();
upper.hash(&mut h_upper);
let mut h_lower = DefaultHasher::new();
lower.hash(&mut h_lower);
assert_eq!(h_upper.finish(), h_lower.finish());
}
}