#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum MessageIdError {
Empty,
MissingAt,
TooManyAtSigns,
InvalidLocal,
InvalidDomain,
}
impl fmt::Display for MessageIdError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("message id value cannot be empty"),
Self::MissingAt => formatter.write_str("message id must contain an at sign"),
Self::TooManyAtSigns => formatter.write_str("message id must contain only one at sign"),
Self::InvalidLocal => formatter.write_str("invalid message id local part"),
Self::InvalidDomain => formatter.write_str("invalid message id domain part"),
}
}
}
impl Error for MessageIdError {}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MessageIdLocal(String);
impl MessageIdLocal {
pub fn new(value: impl AsRef<str>) -> Result<Self, MessageIdError> {
validate_local(value.as_ref()).map(|value| Self(value.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for MessageIdLocal {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for MessageIdLocal {
type Err = MessageIdError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MessageIdDomain(String);
impl MessageIdDomain {
pub fn new(value: impl AsRef<str>) -> Result<Self, MessageIdError> {
validate_domain(value.as_ref()).map(|value| Self(value.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for MessageIdDomain {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for MessageIdDomain {
type Err = MessageIdError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MessageId {
local: MessageIdLocal,
domain: MessageIdDomain,
}
impl MessageId {
pub fn new(local: impl AsRef<str>, domain: impl AsRef<str>) -> Result<Self, MessageIdError> {
Ok(Self {
local: MessageIdLocal::new(local)?,
domain: MessageIdDomain::new(domain)?,
})
}
#[must_use]
pub const fn local(&self) -> &MessageIdLocal {
&self.local
}
#[must_use]
pub const fn domain(&self) -> &MessageIdDomain {
&self.domain
}
#[must_use]
pub fn inner(&self) -> String {
format!("{}@{}", self.local, self.domain)
}
}
impl fmt::Display for MessageId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "<{}@{}>", self.local, self.domain)
}
}
impl FromStr for MessageId {
type Err = MessageIdError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let trimmed = value.trim().trim_start_matches('<').trim_end_matches('>');
if trimmed.is_empty() {
return Err(MessageIdError::Empty);
}
let mut parts = trimmed.split('@');
let local = parts.next().ok_or(MessageIdError::MissingAt)?;
let domain = parts.next().ok_or(MessageIdError::MissingAt)?;
if parts.next().is_some() {
return Err(MessageIdError::TooManyAtSigns);
}
Self::new(local, domain)
}
}
impl TryFrom<&str> for MessageId {
type Error = MessageIdError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
value.parse()
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct References {
message_ids: Vec<MessageId>,
}
impl References {
#[must_use]
pub const fn new() -> Self {
Self {
message_ids: Vec::new(),
}
}
#[must_use]
pub fn with_message_id(mut self, message_id: MessageId) -> Self {
self.message_ids.push(message_id);
self
}
pub fn push(&mut self, message_id: MessageId) {
self.message_ids.push(message_id);
}
#[must_use]
pub fn as_slice(&self) -> &[MessageId] {
&self.message_ids
}
#[must_use]
pub fn len(&self) -> usize {
self.message_ids.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.message_ids.is_empty()
}
}
impl fmt::Display for References {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, message_id) in self.message_ids.iter().enumerate() {
if index > 0 {
formatter.write_str(" ")?;
}
write!(formatter, "{message_id}")?;
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct InReplyTo(MessageId);
impl InReplyTo {
#[must_use]
pub const fn new(message_id: MessageId) -> Self {
Self(message_id)
}
#[must_use]
pub const fn message_id(&self) -> &MessageId {
&self.0
}
}
impl fmt::Display for InReplyTo {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.0)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ThreadReference {
root: MessageId,
replies: Vec<MessageId>,
}
impl ThreadReference {
#[must_use]
pub const fn new(root: MessageId) -> Self {
Self {
root,
replies: Vec::new(),
}
}
#[must_use]
pub fn with_reply(mut self, reply: MessageId) -> Self {
self.replies.push(reply);
self
}
#[must_use]
pub const fn root(&self) -> &MessageId {
&self.root
}
#[must_use]
pub fn replies(&self) -> &[MessageId] {
&self.replies
}
#[must_use]
pub fn references(&self) -> References {
let mut references = References::new().with_message_id(self.root.clone());
for reply in &self.replies {
references.push(reply.clone());
}
references
}
}
fn validate_local(value: &str) -> Result<&str, MessageIdError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(MessageIdError::InvalidLocal);
}
if trimmed.chars().any(|character| {
character.is_control() || character.is_whitespace() || matches!(character, '<' | '>' | '@')
}) {
return Err(MessageIdError::InvalidLocal);
}
Ok(trimmed)
}
fn validate_domain(value: &str) -> Result<&str, MessageIdError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(MessageIdError::InvalidDomain);
}
if trimmed.starts_with('.')
|| trimmed.ends_with('.')
|| trimmed.contains("..")
|| trimmed.chars().any(|character| {
character.is_control()
|| character.is_whitespace()
|| matches!(character, '<' | '>' | '@' | '_')
})
{
return Err(MessageIdError::InvalidDomain);
}
Ok(trimmed)
}
#[cfg(test)]
mod tests {
use super::{InReplyTo, MessageId, MessageIdError, References, ThreadReference};
#[test]
fn parses_and_formats_message_ids() -> Result<(), MessageIdError> {
let message_id: MessageId = "root@example.com".parse()?;
assert_eq!(message_id.inner(), "root@example.com");
assert_eq!(message_id.to_string(), "<root@example.com>");
Ok(())
}
#[test]
fn builds_references_and_threads() -> Result<(), MessageIdError> {
let root: MessageId = "<root@example.com>".parse()?;
let reply: MessageId = "reply@example.com".parse()?;
let references = References::new()
.with_message_id(root.clone())
.with_message_id(reply.clone());
let thread = ThreadReference::new(root.clone()).with_reply(reply);
let in_reply_to = InReplyTo::new(root);
assert_eq!(
references.to_string(),
"<root@example.com> <reply@example.com>"
);
assert_eq!(thread.references(), references);
assert_eq!(in_reply_to.to_string(), "<root@example.com>");
Ok(())
}
#[test]
fn rejects_invalid_message_ids() {
assert_eq!(
"missing-domain@".parse::<MessageId>(),
Err(MessageIdError::InvalidDomain)
);
assert_eq!(
"missing-at".parse::<MessageId>(),
Err(MessageIdError::MissingAt)
);
assert_eq!(
"a@b@c".parse::<MessageId>(),
Err(MessageIdError::TooManyAtSigns)
);
}
}