Skip to main content

use_mailto/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7use use_email_address::{AddressValidationError, EmailAddress};
8
9/// Error returned when mailto primitives fail validation.
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub enum MailtoError {
12    /// Address validation failed.
13    Address(AddressValidationError),
14    /// A field name or value was empty.
15    EmptyField,
16    /// The URI did not start with `mailto:`.
17    InvalidScheme,
18}
19
20impl fmt::Display for MailtoError {
21    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::Address(error) => write!(formatter, "{error}"),
24            Self::EmptyField => formatter.write_str("mailto field cannot be empty"),
25            Self::InvalidScheme => formatter.write_str("mailto URI must start with mailto:"),
26        }
27    }
28}
29
30impl Error for MailtoError {
31    fn source(&self) -> Option<&(dyn Error + 'static)> {
32        match self {
33            Self::Address(error) => Some(error),
34            Self::EmptyField | Self::InvalidScheme => None,
35        }
36    }
37}
38
39impl From<AddressValidationError> for MailtoError {
40    fn from(value: AddressValidationError) -> Self {
41        Self::Address(value)
42    }
43}
44
45/// Address component in a `mailto:` URI.
46#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
47pub struct MailtoAddress(EmailAddress);
48
49impl MailtoAddress {
50    /// Creates a mailto address from address text.
51    pub fn new(value: impl AsRef<str>) -> Result<Self, MailtoError> {
52        Ok(Self(EmailAddress::new(value)?))
53    }
54
55    /// Returns the email address.
56    #[must_use]
57    pub const fn email_address(&self) -> &EmailAddress {
58        &self.0
59    }
60}
61
62impl fmt::Display for MailtoAddress {
63    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64        write!(formatter, "{}", self.0)
65    }
66}
67
68impl FromStr for MailtoAddress {
69    type Err = MailtoError;
70
71    fn from_str(value: &str) -> Result<Self, Self::Err> {
72        Self::new(value)
73    }
74}
75
76/// Standard `mailto:` query field names plus custom fields.
77#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
78pub enum MailtoField {
79    /// `to` field.
80    To,
81    /// `cc` field.
82    Cc,
83    /// `bcc` field.
84    Bcc,
85    /// `subject` field.
86    Subject,
87    /// `body` field.
88    Body,
89    /// Custom field name.
90    Other(String),
91}
92
93impl MailtoField {
94    /// Creates a custom field name.
95    pub fn other(value: impl AsRef<str>) -> Result<Self, MailtoError> {
96        let trimmed = value.as_ref().trim();
97        if trimmed.is_empty() {
98            return Err(MailtoError::EmptyField);
99        }
100        Ok(Self::Other(trimmed.to_ascii_lowercase()))
101    }
102
103    /// Returns the field name.
104    #[must_use]
105    pub fn as_str(&self) -> &str {
106        match self {
107            Self::To => "to",
108            Self::Cc => "cc",
109            Self::Bcc => "bcc",
110            Self::Subject => "subject",
111            Self::Body => "body",
112            Self::Other(value) => value.as_str(),
113        }
114    }
115}
116
117impl fmt::Display for MailtoField {
118    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
119        formatter.write_str(self.as_str())
120    }
121}
122
123/// Query component for a `mailto:` URI.
124#[derive(Clone, Debug, Default, Eq, PartialEq)]
125pub struct MailtoQuery {
126    fields: Vec<(MailtoField, String)>,
127}
128
129impl MailtoQuery {
130    /// Creates an empty query.
131    #[must_use]
132    pub const fn new() -> Self {
133        Self { fields: Vec::new() }
134    }
135
136    /// Adds a query field and returns the updated query.
137    pub fn with_field(
138        mut self,
139        field: MailtoField,
140        value: impl AsRef<str>,
141    ) -> Result<Self, MailtoError> {
142        self.push(field, value)?;
143        Ok(self)
144    }
145
146    /// Appends a query field.
147    pub fn push(&mut self, field: MailtoField, value: impl AsRef<str>) -> Result<(), MailtoError> {
148        let value = value.as_ref();
149        if value.is_empty() {
150            return Err(MailtoError::EmptyField);
151        }
152        self.fields.push((field, value.to_owned()));
153        Ok(())
154    }
155
156    /// Returns query fields.
157    #[must_use]
158    pub fn fields(&self) -> &[(MailtoField, String)] {
159        &self.fields
160    }
161
162    /// Returns true when the query has no fields.
163    #[must_use]
164    pub fn is_empty(&self) -> bool {
165        self.fields.is_empty()
166    }
167}
168
169impl fmt::Display for MailtoQuery {
170    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
171        for (index, (field, value)) in self.fields.iter().enumerate() {
172            if index > 0 {
173                formatter.write_str("&")?;
174            }
175            write!(
176                formatter,
177                "{}={}",
178                encode_component(field.as_str()),
179                encode_component(value)
180            )?;
181        }
182        Ok(())
183    }
184}
185
186/// Complete `mailto:` URI primitive.
187#[derive(Clone, Debug, Default, Eq, PartialEq)]
188pub struct MailtoUri {
189    addresses: Vec<MailtoAddress>,
190    query: MailtoQuery,
191}
192
193impl MailtoUri {
194    /// Creates an empty mailto URI.
195    #[must_use]
196    pub const fn new() -> Self {
197        Self {
198            addresses: Vec::new(),
199            query: MailtoQuery::new(),
200        }
201    }
202
203    /// Adds an address and returns the updated URI.
204    #[must_use]
205    pub fn with_address(mut self, address: MailtoAddress) -> Self {
206        self.addresses.push(address);
207        self
208    }
209
210    /// Sets the query.
211    #[must_use]
212    pub fn with_query(mut self, query: MailtoQuery) -> Self {
213        self.query = query;
214        self
215    }
216
217    /// Returns addresses.
218    #[must_use]
219    pub fn addresses(&self) -> &[MailtoAddress] {
220        &self.addresses
221    }
222
223    /// Returns the query.
224    #[must_use]
225    pub const fn query(&self) -> &MailtoQuery {
226        &self.query
227    }
228}
229
230impl fmt::Display for MailtoUri {
231    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
232        formatter.write_str("mailto:")?;
233        for (index, address) in self.addresses.iter().enumerate() {
234            if index > 0 {
235                formatter.write_str(",")?;
236            }
237            write!(formatter, "{address}")?;
238        }
239        if !self.query.is_empty() {
240            write!(formatter, "?{}", self.query)?;
241        }
242        Ok(())
243    }
244}
245
246impl FromStr for MailtoUri {
247    type Err = MailtoError;
248
249    fn from_str(value: &str) -> Result<Self, Self::Err> {
250        let trimmed = value.trim();
251        let rest = trimmed
252            .strip_prefix("mailto:")
253            .ok_or(MailtoError::InvalidScheme)?;
254        let (address_text, query_text) = rest.split_once('?').unwrap_or((rest, ""));
255        let mut uri = Self::new();
256        for address in address_text
257            .split(',')
258            .filter(|part| !part.trim().is_empty())
259        {
260            uri = uri.with_address(MailtoAddress::new(address)?);
261        }
262        if !query_text.is_empty() {
263            let mut query = MailtoQuery::new();
264            for pair in query_text.split('&') {
265                let (field, value) = pair.split_once('=').ok_or(MailtoError::EmptyField)?;
266                let field = match field.to_ascii_lowercase().as_str() {
267                    "to" => MailtoField::To,
268                    "cc" => MailtoField::Cc,
269                    "bcc" => MailtoField::Bcc,
270                    "subject" => MailtoField::Subject,
271                    "body" => MailtoField::Body,
272                    _ => MailtoField::other(field)?,
273                };
274                query.push(field, value)?;
275            }
276            uri = uri.with_query(query);
277        }
278        Ok(uri)
279    }
280}
281
282/// Builder for common `mailto:` URIs.
283#[derive(Clone, Debug, Default, Eq, PartialEq)]
284pub struct MailtoBuilder {
285    uri: MailtoUri,
286}
287
288impl MailtoBuilder {
289    /// Creates an empty builder.
290    #[must_use]
291    pub const fn new() -> Self {
292        Self {
293            uri: MailtoUri::new(),
294        }
295    }
296
297    /// Adds a primary recipient.
298    pub fn to(mut self, address: impl AsRef<str>) -> Result<Self, MailtoError> {
299        self.uri.addresses.push(MailtoAddress::new(address)?);
300        Ok(self)
301    }
302
303    /// Adds a `cc` field.
304    #[must_use]
305    pub fn cc(mut self, value: impl Into<String>) -> Self {
306        self.uri.query.fields.push((MailtoField::Cc, value.into()));
307        self
308    }
309
310    /// Adds a `bcc` field.
311    #[must_use]
312    pub fn bcc(mut self, value: impl Into<String>) -> Self {
313        self.uri.query.fields.push((MailtoField::Bcc, value.into()));
314        self
315    }
316
317    /// Adds a subject field.
318    #[must_use]
319    pub fn subject(mut self, value: impl Into<String>) -> Self {
320        self.uri
321            .query
322            .fields
323            .push((MailtoField::Subject, value.into()));
324        self
325    }
326
327    /// Adds a body field.
328    #[must_use]
329    pub fn body(mut self, value: impl Into<String>) -> Self {
330        self.uri
331            .query
332            .fields
333            .push((MailtoField::Body, value.into()));
334        self
335    }
336
337    /// Builds the URI.
338    #[must_use]
339    pub fn build(self) -> MailtoUri {
340        self.uri
341    }
342}
343
344fn encode_component(value: &str) -> String {
345    const HEX: &[u8; 16] = b"0123456789ABCDEF";
346
347    let mut encoded = String::new();
348    for byte in value.bytes() {
349        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
350            encoded.push(char::from(byte));
351        } else {
352            encoded.push('%');
353            encoded.push(char::from(HEX[(byte >> 4) as usize]));
354            encoded.push(char::from(HEX[(byte & 0x0f) as usize]));
355        }
356    }
357    encoded
358}
359
360#[cfg(test)]
361mod tests {
362    use super::{MailtoBuilder, MailtoError, MailtoField, MailtoQuery, MailtoUri};
363
364    #[test]
365    fn builds_mailto_uris() -> Result<(), MailtoError> {
366        let uri = MailtoBuilder::new()
367            .to("jane@example.com")?
368            .subject("Hello there")
369            .body("A short note.")
370            .build();
371
372        assert_eq!(
373            uri.to_string(),
374            "mailto:jane@example.com?subject=Hello%20there&body=A%20short%20note."
375        );
376        Ok(())
377    }
378
379    #[test]
380    fn renders_query_fields() -> Result<(), MailtoError> {
381        let query = MailtoQuery::new().with_field(MailtoField::Subject, "Hi there")?;
382
383        assert_eq!(query.to_string(), "subject=Hi%20there");
384        Ok(())
385    }
386
387    #[test]
388    fn parses_simple_mailto_uri() -> Result<(), MailtoError> {
389        let uri: MailtoUri = "mailto:jane@example.com?subject=Hello".parse()?;
390
391        assert_eq!(uri.addresses().len(), 1);
392        assert_eq!(uri.to_string(), "mailto:jane@example.com?subject=Hello");
393        Ok(())
394    }
395}