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#[derive(Clone, Debug, Eq, PartialEq)]
11pub enum MailtoError {
12 Address(AddressValidationError),
14 EmptyField,
16 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
47pub struct MailtoAddress(EmailAddress);
48
49impl MailtoAddress {
50 pub fn new(value: impl AsRef<str>) -> Result<Self, MailtoError> {
52 Ok(Self(EmailAddress::new(value)?))
53 }
54
55 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
78pub enum MailtoField {
79 To,
81 Cc,
83 Bcc,
85 Subject,
87 Body,
89 Other(String),
91}
92
93impl MailtoField {
94 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 #[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#[derive(Clone, Debug, Default, Eq, PartialEq)]
125pub struct MailtoQuery {
126 fields: Vec<(MailtoField, String)>,
127}
128
129impl MailtoQuery {
130 #[must_use]
132 pub const fn new() -> Self {
133 Self { fields: Vec::new() }
134 }
135
136 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 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 #[must_use]
158 pub fn fields(&self) -> &[(MailtoField, String)] {
159 &self.fields
160 }
161
162 #[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#[derive(Clone, Debug, Default, Eq, PartialEq)]
188pub struct MailtoUri {
189 addresses: Vec<MailtoAddress>,
190 query: MailtoQuery,
191}
192
193impl MailtoUri {
194 #[must_use]
196 pub const fn new() -> Self {
197 Self {
198 addresses: Vec::new(),
199 query: MailtoQuery::new(),
200 }
201 }
202
203 #[must_use]
205 pub fn with_address(mut self, address: MailtoAddress) -> Self {
206 self.addresses.push(address);
207 self
208 }
209
210 #[must_use]
212 pub fn with_query(mut self, query: MailtoQuery) -> Self {
213 self.query = query;
214 self
215 }
216
217 #[must_use]
219 pub fn addresses(&self) -> &[MailtoAddress] {
220 &self.addresses
221 }
222
223 #[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#[derive(Clone, Debug, Default, Eq, PartialEq)]
284pub struct MailtoBuilder {
285 uri: MailtoUri,
286}
287
288impl MailtoBuilder {
289 #[must_use]
291 pub const fn new() -> Self {
292 Self {
293 uri: MailtoUri::new(),
294 }
295 }
296
297 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 #[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 #[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 #[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 #[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 #[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}