1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub enum AddressValidationMode {
10 #[default]
12 Practical,
13 StrictAscii,
15 Internationalized,
17}
18
19#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
21pub enum AddressValidationError {
22 Empty,
24 MissingAt,
26 TooManyAtSigns,
28 EmptyLocalPart,
30 EmptyDomain,
32 InvalidLocalPart,
34 InvalidDomain,
36 InvalidDisplayName,
38 NonAscii,
40}
41
42impl fmt::Display for AddressValidationError {
43 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44 match self {
45 Self::Empty => formatter.write_str("email address value cannot be empty"),
46 Self::MissingAt => formatter.write_str("email address must contain an at sign"),
47 Self::TooManyAtSigns => {
48 formatter.write_str("email address must contain only one at sign")
49 }
50 Self::EmptyLocalPart => formatter.write_str("email local part cannot be empty"),
51 Self::EmptyDomain => formatter.write_str("email domain part cannot be empty"),
52 Self::InvalidLocalPart => formatter.write_str("invalid email local part"),
53 Self::InvalidDomain => formatter.write_str("invalid email domain part"),
54 Self::InvalidDisplayName => formatter.write_str("invalid email display name"),
55 Self::NonAscii => {
56 formatter.write_str("email value must be ASCII for this validation mode")
57 }
58 }
59 }
60}
61
62impl Error for AddressValidationError {}
63
64#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
66pub struct LocalPart(String);
67
68impl LocalPart {
69 pub fn new(value: impl AsRef<str>) -> Result<Self, AddressValidationError> {
71 Self::new_with_mode(value, AddressValidationMode::Practical)
72 }
73
74 pub fn new_with_mode(
76 value: impl AsRef<str>,
77 mode: AddressValidationMode,
78 ) -> Result<Self, AddressValidationError> {
79 validate_local_part(value.as_ref(), mode).map(|value| Self(value.to_owned()))
80 }
81
82 #[must_use]
84 pub fn as_str(&self) -> &str {
85 &self.0
86 }
87}
88
89impl AsRef<str> for LocalPart {
90 fn as_ref(&self) -> &str {
91 self.as_str()
92 }
93}
94
95impl fmt::Display for LocalPart {
96 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97 formatter.write_str(self.as_str())
98 }
99}
100
101impl FromStr for LocalPart {
102 type Err = AddressValidationError;
103
104 fn from_str(value: &str) -> Result<Self, Self::Err> {
105 Self::new(value)
106 }
107}
108
109impl TryFrom<&str> for LocalPart {
110 type Error = AddressValidationError;
111
112 fn try_from(value: &str) -> Result<Self, Self::Error> {
113 Self::new(value)
114 }
115}
116
117#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
119pub struct DomainPart(String);
120
121impl DomainPart {
122 pub fn new(value: impl AsRef<str>) -> Result<Self, AddressValidationError> {
124 Self::new_with_mode(value, AddressValidationMode::Practical)
125 }
126
127 pub fn new_with_mode(
129 value: impl AsRef<str>,
130 mode: AddressValidationMode,
131 ) -> Result<Self, AddressValidationError> {
132 validate_domain_part(value.as_ref(), mode).map(|value| Self(value.to_owned()))
133 }
134
135 #[must_use]
137 pub fn as_str(&self) -> &str {
138 &self.0
139 }
140}
141
142impl AsRef<str> for DomainPart {
143 fn as_ref(&self) -> &str {
144 self.as_str()
145 }
146}
147
148impl fmt::Display for DomainPart {
149 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
150 formatter.write_str(self.as_str())
151 }
152}
153
154impl FromStr for DomainPart {
155 type Err = AddressValidationError;
156
157 fn from_str(value: &str) -> Result<Self, Self::Err> {
158 Self::new(value)
159 }
160}
161
162impl TryFrom<&str> for DomainPart {
163 type Error = AddressValidationError;
164
165 fn try_from(value: &str) -> Result<Self, Self::Error> {
166 Self::new(value)
167 }
168}
169
170#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
172pub struct EmailAddress {
173 local_part: LocalPart,
174 domain_part: DomainPart,
175}
176
177impl EmailAddress {
178 pub fn new(value: impl AsRef<str>) -> Result<Self, AddressValidationError> {
180 Self::new_with_mode(value, AddressValidationMode::Practical)
181 }
182
183 pub fn new_with_mode(
185 value: impl AsRef<str>,
186 mode: AddressValidationMode,
187 ) -> Result<Self, AddressValidationError> {
188 let trimmed = value.as_ref().trim();
189 if trimmed.is_empty() {
190 return Err(AddressValidationError::Empty);
191 }
192 let mut parts = trimmed.split('@');
193 let local = parts.next().ok_or(AddressValidationError::MissingAt)?;
194 let domain = parts.next().ok_or(AddressValidationError::MissingAt)?;
195 if parts.next().is_some() {
196 return Err(AddressValidationError::TooManyAtSigns);
197 }
198 Self::from_parts_with_mode(local, domain, mode)
199 }
200
201 pub fn from_parts(
203 local_part: impl AsRef<str>,
204 domain_part: impl AsRef<str>,
205 ) -> Result<Self, AddressValidationError> {
206 Self::from_parts_with_mode(local_part, domain_part, AddressValidationMode::Practical)
207 }
208
209 pub fn from_parts_with_mode(
211 local_part: impl AsRef<str>,
212 domain_part: impl AsRef<str>,
213 mode: AddressValidationMode,
214 ) -> Result<Self, AddressValidationError> {
215 Ok(Self {
216 local_part: LocalPart::new_with_mode(local_part, mode)?,
217 domain_part: DomainPart::new_with_mode(domain_part, mode)?,
218 })
219 }
220
221 #[must_use]
223 pub const fn local_part(&self) -> &LocalPart {
224 &self.local_part
225 }
226
227 #[must_use]
229 pub const fn domain_part(&self) -> &DomainPart {
230 &self.domain_part
231 }
232
233 #[must_use]
235 pub fn into_string(self) -> String {
236 self.to_string()
237 }
238}
239
240impl fmt::Display for EmailAddress {
241 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
242 write!(formatter, "{}@{}", self.local_part, self.domain_part)
243 }
244}
245
246impl FromStr for EmailAddress {
247 type Err = AddressValidationError;
248
249 fn from_str(value: &str) -> Result<Self, Self::Err> {
250 Self::new(value)
251 }
252}
253
254impl TryFrom<&str> for EmailAddress {
255 type Error = AddressValidationError;
256
257 fn try_from(value: &str) -> Result<Self, Self::Error> {
258 Self::new(value)
259 }
260}
261
262#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
264pub struct DisplayName(String);
265
266impl DisplayName {
267 pub fn new(value: impl AsRef<str>) -> Result<Self, AddressValidationError> {
269 validate_display_name(value.as_ref()).map(|value| Self(value.to_owned()))
270 }
271
272 #[must_use]
274 pub fn as_str(&self) -> &str {
275 &self.0
276 }
277}
278
279impl AsRef<str> for DisplayName {
280 fn as_ref(&self) -> &str {
281 self.as_str()
282 }
283}
284
285impl fmt::Display for DisplayName {
286 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
287 formatter.write_str(self.as_str())
288 }
289}
290
291impl FromStr for DisplayName {
292 type Err = AddressValidationError;
293
294 fn from_str(value: &str) -> Result<Self, Self::Err> {
295 Self::new(value)
296 }
297}
298
299#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
301pub struct Mailbox {
302 display_name: Option<DisplayName>,
303 address: EmailAddress,
304}
305
306impl Mailbox {
307 pub fn new(
309 display_name: Option<&str>,
310 address: impl AsRef<str>,
311 ) -> Result<Self, AddressValidationError> {
312 Ok(Self {
313 display_name: display_name.map(DisplayName::new).transpose()?,
314 address: EmailAddress::new(address)?,
315 })
316 }
317
318 #[must_use]
320 pub const fn from_address(address: EmailAddress) -> Self {
321 Self {
322 display_name: None,
323 address,
324 }
325 }
326
327 pub fn with_display_name(
329 mut self,
330 display_name: impl AsRef<str>,
331 ) -> Result<Self, AddressValidationError> {
332 self.display_name = Some(DisplayName::new(display_name)?);
333 Ok(self)
334 }
335
336 #[must_use]
338 pub const fn display_name(&self) -> Option<&DisplayName> {
339 self.display_name.as_ref()
340 }
341
342 #[must_use]
344 pub const fn address(&self) -> &EmailAddress {
345 &self.address
346 }
347}
348
349impl fmt::Display for Mailbox {
350 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
351 if let Some(display_name) = &self.display_name {
352 write!(
353 formatter,
354 "\"{}\" <{}>",
355 escape_display_name(display_name.as_str()),
356 self.address
357 )
358 } else {
359 write!(formatter, "{}", self.address)
360 }
361 }
362}
363
364impl FromStr for Mailbox {
365 type Err = AddressValidationError;
366
367 fn from_str(value: &str) -> Result<Self, Self::Err> {
368 let trimmed = value.trim();
369 if trimmed.is_empty() {
370 return Err(AddressValidationError::Empty);
371 }
372 if let Some(start) = trimmed.rfind('<') {
373 let end = trimmed
374 .rfind('>')
375 .ok_or(AddressValidationError::InvalidLocalPart)?;
376 if end <= start {
377 return Err(AddressValidationError::InvalidLocalPart);
378 }
379 let display = trimmed[..start].trim().trim_matches('"').trim();
380 let address = trimmed[start + 1..end].trim();
381 let display_name = if display.is_empty() {
382 None
383 } else {
384 Some(display)
385 };
386 Self::new(display_name, address)
387 } else {
388 Self::new(None, trimmed)
389 }
390 }
391}
392
393#[derive(Clone, Debug, Default, Eq, PartialEq)]
395pub struct MailboxList {
396 mailboxes: Vec<Mailbox>,
397}
398
399impl MailboxList {
400 #[must_use]
402 pub const fn new() -> Self {
403 Self {
404 mailboxes: Vec::new(),
405 }
406 }
407
408 #[must_use]
410 pub fn with_mailbox(mut self, mailbox: Mailbox) -> Self {
411 self.mailboxes.push(mailbox);
412 self
413 }
414
415 pub fn push(&mut self, mailbox: Mailbox) {
417 self.mailboxes.push(mailbox);
418 }
419
420 #[must_use]
422 pub fn as_slice(&self) -> &[Mailbox] {
423 &self.mailboxes
424 }
425
426 #[must_use]
428 pub fn len(&self) -> usize {
429 self.mailboxes.len()
430 }
431
432 #[must_use]
434 pub fn is_empty(&self) -> bool {
435 self.mailboxes.is_empty()
436 }
437}
438
439impl fmt::Display for MailboxList {
440 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
441 for (index, mailbox) in self.mailboxes.iter().enumerate() {
442 if index > 0 {
443 formatter.write_str(", ")?;
444 }
445 write!(formatter, "{mailbox}")?;
446 }
447 Ok(())
448 }
449}
450
451#[derive(Clone, Debug, Eq, PartialEq)]
453pub struct AddressGroup {
454 name: DisplayName,
455 members: MailboxList,
456}
457
458impl AddressGroup {
459 pub fn new(
461 name: impl AsRef<str>,
462 members: MailboxList,
463 ) -> Result<Self, AddressValidationError> {
464 Ok(Self {
465 name: DisplayName::new(name)?,
466 members,
467 })
468 }
469
470 #[must_use]
472 pub const fn name(&self) -> &DisplayName {
473 &self.name
474 }
475
476 #[must_use]
478 pub const fn members(&self) -> &MailboxList {
479 &self.members
480 }
481}
482
483impl fmt::Display for AddressGroup {
484 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
485 write!(formatter, "{}: {};", self.name, self.members)
486 }
487}
488
489fn validate_local_part(
490 value: &str,
491 mode: AddressValidationMode,
492) -> Result<&str, AddressValidationError> {
493 let trimmed = value.trim();
494 if trimmed.is_empty() {
495 return Err(AddressValidationError::EmptyLocalPart);
496 }
497 if mode != AddressValidationMode::Internationalized && !trimmed.is_ascii() {
498 return Err(AddressValidationError::NonAscii);
499 }
500 if trimmed.starts_with('.') || trimmed.ends_with('.') || trimmed.contains("..") {
501 return Err(AddressValidationError::InvalidLocalPart);
502 }
503 if trimmed.chars().any(|character| {
504 character.is_control()
505 || character.is_whitespace()
506 || matches!(character, '@' | '<' | '>' | ',' | ';')
507 || (mode != AddressValidationMode::Internationalized && !is_local_ascii(character))
508 }) {
509 return Err(AddressValidationError::InvalidLocalPart);
510 }
511 Ok(trimmed)
512}
513
514fn validate_domain_part(
515 value: &str,
516 mode: AddressValidationMode,
517) -> Result<&str, AddressValidationError> {
518 let trimmed = value.trim().trim_end_matches('.');
519 if trimmed.is_empty() {
520 return Err(AddressValidationError::EmptyDomain);
521 }
522 if mode != AddressValidationMode::Internationalized && !trimmed.is_ascii() {
523 return Err(AddressValidationError::NonAscii);
524 }
525 if trimmed.starts_with('.') || trimmed.contains("..") {
526 return Err(AddressValidationError::InvalidDomain);
527 }
528 for label in trimmed.split('.') {
529 if label.is_empty() || label.starts_with('-') || label.ends_with('-') {
530 return Err(AddressValidationError::InvalidDomain);
531 }
532 if label.chars().any(|character| {
533 character.is_control()
534 || character.is_whitespace()
535 || matches!(character, '@' | '<' | '>' | ',' | ';' | '_')
536 || (mode != AddressValidationMode::Internationalized && !is_domain_ascii(character))
537 }) {
538 return Err(AddressValidationError::InvalidDomain);
539 }
540 }
541 Ok(trimmed)
542}
543
544fn validate_display_name(value: &str) -> Result<&str, AddressValidationError> {
545 let trimmed = value.trim();
546 if trimmed.is_empty() {
547 return Err(AddressValidationError::Empty);
548 }
549 if trimmed
550 .chars()
551 .any(|character| character.is_control() || matches!(character, '<' | '>' | '\r' | '\n'))
552 {
553 return Err(AddressValidationError::InvalidDisplayName);
554 }
555 Ok(trimmed)
556}
557
558fn is_local_ascii(character: char) -> bool {
559 character.is_ascii_alphanumeric()
560 || matches!(
561 character,
562 '!' | '#'
563 | '$'
564 | '%'
565 | '&'
566 | '\''
567 | '*'
568 | '+'
569 | '-'
570 | '/'
571 | '='
572 | '?'
573 | '^'
574 | '_'
575 | '`'
576 | '{'
577 | '|'
578 | '}'
579 | '~'
580 | '.'
581 )
582}
583
584fn is_domain_ascii(character: char) -> bool {
585 character.is_ascii_alphanumeric() || matches!(character, '-' | '.')
586}
587
588fn escape_display_name(value: &str) -> String {
589 let mut escaped = String::new();
590 for character in value.chars() {
591 if matches!(character, '\\' | '"') {
592 escaped.push('\\');
593 }
594 escaped.push(character);
595 }
596 escaped
597}
598
599#[cfg(test)]
600mod tests {
601 use super::{
602 AddressGroup, AddressValidationError, AddressValidationMode, EmailAddress, Mailbox,
603 MailboxList,
604 };
605
606 #[test]
607 fn parses_practical_addresses() -> Result<(), AddressValidationError> {
608 let address: EmailAddress = "jane.doe+notes@example.com".parse()?;
609
610 assert_eq!(address.local_part().as_str(), "jane.doe+notes");
611 assert_eq!(address.domain_part().as_str(), "example.com");
612 assert_eq!(address.to_string(), "jane.doe+notes@example.com");
613 Ok(())
614 }
615
616 #[test]
617 fn validation_modes_are_explicit() {
618 assert_eq!(
619 EmailAddress::new_with_mode("jane@exämple.test", AddressValidationMode::StrictAscii),
620 Err(AddressValidationError::NonAscii)
621 );
622 assert!(
623 EmailAddress::new_with_mode(
624 "jane@exämple.test",
625 AddressValidationMode::Internationalized
626 )
627 .is_ok()
628 );
629 }
630
631 #[test]
632 fn renders_mailbox_lists_and_groups() -> Result<(), AddressValidationError> {
633 let jane = Mailbox::new(Some("Jane Doe"), "jane@example.com")?;
634 let ada: Mailbox = "Ada <ada@example.com>".parse()?;
635 let list = MailboxList::new().with_mailbox(jane).with_mailbox(ada);
636 let group = AddressGroup::new("Team", list)?;
637
638 assert_eq!(
639 group.to_string(),
640 "Team: \"Jane Doe\" <jane@example.com>, \"Ada\" <ada@example.com>;"
641 );
642 Ok(())
643 }
644
645 #[test]
646 fn rejects_obvious_invalid_addresses() {
647 assert_eq!(
648 EmailAddress::new("jane.example.com"),
649 Err(AddressValidationError::MissingAt)
650 );
651 assert_eq!(
652 EmailAddress::new("jane@@example.com"),
653 Err(AddressValidationError::TooManyAtSigns)
654 );
655 assert_eq!(
656 EmailAddress::new("jane@-example.com"),
657 Err(AddressValidationError::InvalidDomain)
658 );
659 }
660}