#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
use use_email_address::{AddressValidationError, EmailAddress};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MailtoError {
Address(AddressValidationError),
EmptyField,
InvalidScheme,
}
impl fmt::Display for MailtoError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Address(error) => write!(formatter, "{error}"),
Self::EmptyField => formatter.write_str("mailto field cannot be empty"),
Self::InvalidScheme => formatter.write_str("mailto URI must start with mailto:"),
}
}
}
impl Error for MailtoError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Address(error) => Some(error),
Self::EmptyField | Self::InvalidScheme => None,
}
}
}
impl From<AddressValidationError> for MailtoError {
fn from(value: AddressValidationError) -> Self {
Self::Address(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MailtoAddress(EmailAddress);
impl MailtoAddress {
pub fn new(value: impl AsRef<str>) -> Result<Self, MailtoError> {
Ok(Self(EmailAddress::new(value)?))
}
#[must_use]
pub const fn email_address(&self) -> &EmailAddress {
&self.0
}
}
impl fmt::Display for MailtoAddress {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.0)
}
}
impl FromStr for MailtoAddress {
type Err = MailtoError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum MailtoField {
To,
Cc,
Bcc,
Subject,
Body,
Other(String),
}
impl MailtoField {
pub fn other(value: impl AsRef<str>) -> Result<Self, MailtoError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
return Err(MailtoError::EmptyField);
}
Ok(Self::Other(trimmed.to_ascii_lowercase()))
}
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::To => "to",
Self::Cc => "cc",
Self::Bcc => "bcc",
Self::Subject => "subject",
Self::Body => "body",
Self::Other(value) => value.as_str(),
}
}
}
impl fmt::Display for MailtoField {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MailtoQuery {
fields: Vec<(MailtoField, String)>,
}
impl MailtoQuery {
#[must_use]
pub const fn new() -> Self {
Self { fields: Vec::new() }
}
pub fn with_field(
mut self,
field: MailtoField,
value: impl AsRef<str>,
) -> Result<Self, MailtoError> {
self.push(field, value)?;
Ok(self)
}
pub fn push(&mut self, field: MailtoField, value: impl AsRef<str>) -> Result<(), MailtoError> {
let value = value.as_ref();
if value.is_empty() {
return Err(MailtoError::EmptyField);
}
self.fields.push((field, value.to_owned()));
Ok(())
}
#[must_use]
pub fn fields(&self) -> &[(MailtoField, String)] {
&self.fields
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
}
impl fmt::Display for MailtoQuery {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, (field, value)) in self.fields.iter().enumerate() {
if index > 0 {
formatter.write_str("&")?;
}
write!(
formatter,
"{}={}",
encode_component(field.as_str()),
encode_component(value)
)?;
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MailtoUri {
addresses: Vec<MailtoAddress>,
query: MailtoQuery,
}
impl MailtoUri {
#[must_use]
pub const fn new() -> Self {
Self {
addresses: Vec::new(),
query: MailtoQuery::new(),
}
}
#[must_use]
pub fn with_address(mut self, address: MailtoAddress) -> Self {
self.addresses.push(address);
self
}
#[must_use]
pub fn with_query(mut self, query: MailtoQuery) -> Self {
self.query = query;
self
}
#[must_use]
pub fn addresses(&self) -> &[MailtoAddress] {
&self.addresses
}
#[must_use]
pub const fn query(&self) -> &MailtoQuery {
&self.query
}
}
impl fmt::Display for MailtoUri {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("mailto:")?;
for (index, address) in self.addresses.iter().enumerate() {
if index > 0 {
formatter.write_str(",")?;
}
write!(formatter, "{address}")?;
}
if !self.query.is_empty() {
write!(formatter, "?{}", self.query)?;
}
Ok(())
}
}
impl FromStr for MailtoUri {
type Err = MailtoError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let trimmed = value.trim();
let rest = trimmed
.strip_prefix("mailto:")
.ok_or(MailtoError::InvalidScheme)?;
let (address_text, query_text) = rest.split_once('?').unwrap_or((rest, ""));
let mut uri = Self::new();
for address in address_text
.split(',')
.filter(|part| !part.trim().is_empty())
{
uri = uri.with_address(MailtoAddress::new(address)?);
}
if !query_text.is_empty() {
let mut query = MailtoQuery::new();
for pair in query_text.split('&') {
let (field, value) = pair.split_once('=').ok_or(MailtoError::EmptyField)?;
let field = match field.to_ascii_lowercase().as_str() {
"to" => MailtoField::To,
"cc" => MailtoField::Cc,
"bcc" => MailtoField::Bcc,
"subject" => MailtoField::Subject,
"body" => MailtoField::Body,
_ => MailtoField::other(field)?,
};
query.push(field, value)?;
}
uri = uri.with_query(query);
}
Ok(uri)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MailtoBuilder {
uri: MailtoUri,
}
impl MailtoBuilder {
#[must_use]
pub const fn new() -> Self {
Self {
uri: MailtoUri::new(),
}
}
pub fn to(mut self, address: impl AsRef<str>) -> Result<Self, MailtoError> {
self.uri.addresses.push(MailtoAddress::new(address)?);
Ok(self)
}
#[must_use]
pub fn cc(mut self, value: impl Into<String>) -> Self {
self.uri.query.fields.push((MailtoField::Cc, value.into()));
self
}
#[must_use]
pub fn bcc(mut self, value: impl Into<String>) -> Self {
self.uri.query.fields.push((MailtoField::Bcc, value.into()));
self
}
#[must_use]
pub fn subject(mut self, value: impl Into<String>) -> Self {
self.uri
.query
.fields
.push((MailtoField::Subject, value.into()));
self
}
#[must_use]
pub fn body(mut self, value: impl Into<String>) -> Self {
self.uri
.query
.fields
.push((MailtoField::Body, value.into()));
self
}
#[must_use]
pub fn build(self) -> MailtoUri {
self.uri
}
}
fn encode_component(value: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut encoded = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(char::from(byte));
} else {
encoded.push('%');
encoded.push(char::from(HEX[(byte >> 4) as usize]));
encoded.push(char::from(HEX[(byte & 0x0f) as usize]));
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::{MailtoBuilder, MailtoError, MailtoField, MailtoQuery, MailtoUri};
#[test]
fn builds_mailto_uris() -> Result<(), MailtoError> {
let uri = MailtoBuilder::new()
.to("jane@example.com")?
.subject("Hello there")
.body("A short note.")
.build();
assert_eq!(
uri.to_string(),
"mailto:jane@example.com?subject=Hello%20there&body=A%20short%20note."
);
Ok(())
}
#[test]
fn renders_query_fields() -> Result<(), MailtoError> {
let query = MailtoQuery::new().with_field(MailtoField::Subject, "Hi there")?;
assert_eq!(query.to_string(), "subject=Hi%20there");
Ok(())
}
#[test]
fn parses_simple_mailto_uri() -> Result<(), MailtoError> {
let uri: MailtoUri = "mailto:jane@example.com?subject=Hello".parse()?;
assert_eq!(uri.addresses().len(), 1);
assert_eq!(uri.to_string(), "mailto:jane@example.com?subject=Hello");
Ok(())
}
}