1use std::fmt;
14use std::net::{Ipv4Addr, Ipv6Addr};
15
16pub use daaki_message::ValidationError;
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
30pub struct Domain(String);
31
32impl Domain {
33 pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
35 let s = s.into();
36 validate_domain_syntax(&s)?;
37 Ok(Self(s))
38 }
39
40 pub fn as_str(&self) -> &str {
42 &self.0
43 }
44
45 pub fn into_inner(self) -> String {
47 self.0
48 }
49}
50
51impl AsRef<str> for Domain {
52 fn as_ref(&self) -> &str {
53 &self.0
54 }
55}
56
57impl fmt::Display for Domain {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.write_str(&self.0)
60 }
61}
62
63impl TryFrom<String> for Domain {
64 type Error = ValidationError;
65 fn try_from(s: String) -> Result<Self, ValidationError> {
66 Self::new(s)
67 }
68}
69
70impl TryFrom<&str> for Domain {
71 type Error = ValidationError;
72 fn try_from(s: &str) -> Result<Self, ValidationError> {
73 Self::new(s)
74 }
75}
76
77impl From<Domain> for String {
78 fn from(d: Domain) -> Self {
79 d.into_inner()
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Hash)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
94pub struct AddressLiteral(String);
95
96impl AddressLiteral {
97 pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
101 let s = s.into();
102 validate_address_literal_syntax(&s)?;
103 Ok(Self(s))
104 }
105
106 pub fn as_str(&self) -> &str {
108 &self.0
109 }
110
111 pub fn into_inner(self) -> String {
113 self.0
114 }
115}
116
117impl AsRef<str> for AddressLiteral {
118 fn as_ref(&self) -> &str {
119 &self.0
120 }
121}
122
123impl fmt::Display for AddressLiteral {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 f.write_str(&self.0)
126 }
127}
128
129impl TryFrom<String> for AddressLiteral {
130 type Error = ValidationError;
131 fn try_from(s: String) -> Result<Self, ValidationError> {
132 Self::new(s)
133 }
134}
135
136impl TryFrom<&str> for AddressLiteral {
137 type Error = ValidationError;
138 fn try_from(s: &str) -> Result<Self, ValidationError> {
139 Self::new(s)
140 }
141}
142
143impl From<AddressLiteral> for String {
144 fn from(a: AddressLiteral) -> Self {
145 a.into_inner()
146 }
147}
148
149#[non_exhaustive]
157#[derive(Debug, Clone, PartialEq, Eq, Hash)]
158#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
159pub enum DomainOrLiteral {
160 Domain(Domain),
162 Literal(AddressLiteral),
164}
165
166impl DomainOrLiteral {
167 pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
169 let s = s.into();
170 if s.is_empty() {
171 return Err(ValidationError::new(
172 "SMTP greeting domain must not be empty (RFC 5321 Section 4.1.1.1)",
173 ));
174 }
175 if s.starts_with('[') && s.ends_with(']') {
176 Ok(Self::Literal(AddressLiteral::new(s)?))
177 } else {
178 Ok(Self::Domain(Domain::new(s)?))
179 }
180 }
181
182 pub fn as_str(&self) -> &str {
184 match self {
185 Self::Domain(d) => d.as_str(),
186 Self::Literal(l) => l.as_str(),
187 }
188 }
189}
190
191impl AsRef<str> for DomainOrLiteral {
192 fn as_ref(&self) -> &str {
193 self.as_str()
194 }
195}
196
197impl fmt::Display for DomainOrLiteral {
198 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199 f.write_str(self.as_str())
200 }
201}
202
203impl TryFrom<String> for DomainOrLiteral {
204 type Error = ValidationError;
205 fn try_from(s: String) -> Result<Self, ValidationError> {
206 Self::new(s)
207 }
208}
209
210impl TryFrom<&str> for DomainOrLiteral {
211 type Error = ValidationError;
212 fn try_from(s: &str) -> Result<Self, ValidationError> {
213 Self::new(s)
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Hash)]
227#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
228#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
229pub struct Mailbox(String);
230
231impl Mailbox {
232 pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
237 let s = s.into();
238 validate_mailbox_syntax(&s)?;
239 Ok(Self(strip_obsolete_source_route(&s)?.to_string()))
240 }
241
242 pub fn as_str(&self) -> &str {
244 &self.0
245 }
246
247 pub fn into_inner(self) -> String {
249 self.0
250 }
251
252 pub fn requires_smtputf8(&self) -> bool {
255 !self.0.is_ascii()
256 }
257}
258
259impl AsRef<str> for Mailbox {
260 fn as_ref(&self) -> &str {
261 &self.0
262 }
263}
264
265impl fmt::Display for Mailbox {
266 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267 f.write_str(&self.0)
268 }
269}
270
271impl TryFrom<String> for Mailbox {
272 type Error = ValidationError;
273 fn try_from(s: String) -> Result<Self, ValidationError> {
274 Self::new(s)
275 }
276}
277
278impl TryFrom<&str> for Mailbox {
279 type Error = ValidationError;
280 fn try_from(s: &str) -> Result<Self, ValidationError> {
281 Self::new(s)
282 }
283}
284
285impl From<Mailbox> for String {
286 fn from(m: Mailbox) -> Self {
287 m.into_inner()
288 }
289}
290
291#[non_exhaustive]
302#[derive(Debug, Clone, PartialEq, Eq, Hash)]
303#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
304pub enum ReversePath {
305 Null,
307 Mailbox(Mailbox),
309}
310
311impl ReversePath {
312 pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
314 let s = s.into();
315 if s.is_empty() {
316 Ok(Self::Null)
317 } else {
318 Ok(Self::Mailbox(Mailbox::new(s)?))
319 }
320 }
321
322 pub fn as_str(&self) -> &str {
324 match self {
325 Self::Null => "",
326 Self::Mailbox(m) => m.as_str(),
327 }
328 }
329
330 pub fn requires_smtputf8(&self) -> bool {
333 match self {
334 Self::Null => false,
335 Self::Mailbox(m) => m.requires_smtputf8(),
336 }
337 }
338}
339
340impl fmt::Display for ReversePath {
341 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342 f.write_str(self.as_str())
343 }
344}
345
346impl TryFrom<String> for ReversePath {
347 type Error = ValidationError;
348 fn try_from(s: String) -> Result<Self, ValidationError> {
349 Self::new(s)
350 }
351}
352
353impl TryFrom<&str> for ReversePath {
354 type Error = ValidationError;
355 fn try_from(s: &str) -> Result<Self, ValidationError> {
356 Self::new(s)
357 }
358}
359
360#[non_exhaustive]
371#[derive(Debug, Clone, PartialEq, Eq, Hash)]
372#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
373pub enum ForwardPath {
374 Postmaster,
376 Mailbox(Mailbox),
378}
379
380impl ForwardPath {
381 pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
383 let s = s.into();
384 if s.eq_ignore_ascii_case("postmaster") {
385 Ok(Self::Postmaster)
386 } else {
387 Ok(Self::Mailbox(Mailbox::new(s)?))
388 }
389 }
390
391 pub fn as_str(&self) -> &str {
393 match self {
394 Self::Postmaster => "Postmaster",
395 Self::Mailbox(m) => m.as_str(),
396 }
397 }
398
399 pub fn requires_smtputf8(&self) -> bool {
401 match self {
402 Self::Postmaster => false,
403 Self::Mailbox(m) => m.requires_smtputf8(),
404 }
405 }
406}
407
408impl fmt::Display for ForwardPath {
409 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410 f.write_str(self.as_str())
411 }
412}
413
414impl TryFrom<String> for ForwardPath {
415 type Error = ValidationError;
416 fn try_from(s: String) -> Result<Self, ValidationError> {
417 Self::new(s)
418 }
419}
420
421impl TryFrom<&str> for ForwardPath {
422 type Error = ValidationError;
423 fn try_from(s: &str) -> Result<Self, ValidationError> {
424 Self::new(s)
425 }
426}
427
428#[derive(Debug, Clone, PartialEq, Eq, Hash)]
437#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
438#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
439pub struct EnvidValue(String);
440
441impl EnvidValue {
442 pub fn into_inner(self) -> String {
444 self.0
445 }
446
447 pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
449 let s = s.into();
450 if s.is_empty() {
451 return Err(ValidationError::new(
452 "ENVID value must be non-empty (RFC 3461 Section 4.4: xtext = 1*xchar)",
453 ));
454 }
455 if !s
456 .bytes()
457 .all(|b| b.is_ascii() && (0x20..=0x7E).contains(&b))
458 {
459 return Err(ValidationError::new(
460 "ENVID source value must contain only printable US-ASCII before xtext encoding \
461 (RFC 3461 Section 4.4)",
462 ));
463 }
464 if s.len() > 100 {
465 return Err(ValidationError::new(
466 "ENVID value must be at most 100 characters before xtext encoding \
467 (RFC 3461 Section 4.4)",
468 ));
469 }
470 Ok(Self(s))
471 }
472
473 pub fn as_str(&self) -> &str {
475 &self.0
476 }
477}
478
479impl AsRef<str> for EnvidValue {
480 fn as_ref(&self) -> &str {
481 &self.0
482 }
483}
484
485impl fmt::Display for EnvidValue {
486 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
487 f.write_str(&self.0)
488 }
489}
490
491impl TryFrom<String> for EnvidValue {
492 type Error = ValidationError;
493 fn try_from(s: String) -> Result<Self, ValidationError> {
494 Self::new(s)
495 }
496}
497
498impl TryFrom<&str> for EnvidValue {
499 type Error = ValidationError;
500 fn try_from(s: &str) -> Result<Self, ValidationError> {
501 Self::new(s)
502 }
503}
504
505impl From<EnvidValue> for String {
506 fn from(e: EnvidValue) -> Self {
507 e.into_inner()
508 }
509}
510
511#[derive(Debug, Clone, PartialEq, Eq, Hash)]
521#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
522#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
523pub struct XtextSafe(String);
524
525impl XtextSafe {
526 pub fn into_inner(self) -> String {
528 self.0
529 }
530
531 pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
533 let s = s.into();
534 if s.is_empty() {
535 return Err(ValidationError::new(
536 "SMTP query argument must not be empty \
537 (RFC 5321 Sections 4.1.1.6-4.1.1.7)",
538 ));
539 }
540 for &b in s.as_bytes() {
541 if b < 0x20 || b == 0x7F {
542 return Err(ValidationError::new(format!(
543 "SMTP argument contains control character (byte 0x{b:02X}); \
544 only printable US-ASCII is permitted (RFC 5321 Section 4.1.2)"
545 )));
546 }
547 if b > 0x7F {
548 return Err(ValidationError::new(
549 "SMTP argument contains non-ASCII characters; \
550 only printable US-ASCII is permitted (RFC 5321 Section 4.1.2)",
551 ));
552 }
553 }
554 Ok(Self(s))
558 }
559
560 pub fn as_str(&self) -> &str {
562 &self.0
563 }
564}
565
566impl AsRef<str> for XtextSafe {
567 fn as_ref(&self) -> &str {
568 &self.0
569 }
570}
571
572impl fmt::Display for XtextSafe {
573 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
574 f.write_str(&self.0)
575 }
576}
577
578impl TryFrom<String> for XtextSafe {
579 type Error = ValidationError;
580 fn try_from(s: String) -> Result<Self, ValidationError> {
581 Self::new(s)
582 }
583}
584
585impl TryFrom<&str> for XtextSafe {
586 type Error = ValidationError;
587 fn try_from(s: &str) -> Result<Self, ValidationError> {
588 Self::new(s)
589 }
590}
591
592impl From<XtextSafe> for String {
593 fn from(x: XtextSafe) -> Self {
594 x.into_inner()
595 }
596}
597
598fn validate_domain_syntax(domain: &str) -> Result<(), ValidationError> {
604 if domain.is_empty() {
605 return Err(ValidationError::new(
606 "domain must not be empty (RFC 5321 Section 4.1.2)",
607 ));
608 }
609 if domain.len() > 255 {
611 return Err(ValidationError::new(format!(
612 "domain exceeds 255-octet limit (RFC 5321 Section 4.5.3.1.2): {} octets",
613 domain.len()
614 )));
615 }
616 if domain.starts_with('[') && domain.ends_with(']') {
617 return Err(ValidationError::new(
618 "value is an address-literal, not a domain (RFC 5321 Section 4.1.2)",
619 ));
620 }
621 for label in domain.split('.') {
622 validate_domain_label(label)?;
623 }
624 Ok(())
625}
626
627pub(crate) fn validate_smtputf8_domain_syntax(domain: &str) -> Result<(), ValidationError> {
634 if domain.is_empty() {
635 return Err(ValidationError::new(
636 "domain must not be empty (RFC 5321 Section 4.1.2 / RFC 6531 Section 3.3)",
637 ));
638 }
639 if domain.len() > 255 {
641 return Err(ValidationError::new(format!(
642 "domain exceeds 255-octet limit (RFC 5321 Section 4.5.3.1.2): {} octets",
643 domain.len()
644 )));
645 }
646 if domain.starts_with('[') && domain.ends_with(']') {
647 return Err(ValidationError::new(
648 "value is an address-literal, not a domain (RFC 5321 Section 4.1.2)",
649 ));
650 }
651
652 for label in domain.split('.') {
653 if label.is_empty() {
654 return Err(ValidationError::new(
655 "domain contains an empty label (RFC 5321 Section 4.1.2 / RFC 6531 Section 3.3)",
656 ));
657 }
658 if label.is_ascii() {
659 validate_domain_label(label)?;
660 continue;
661 }
662 if label.len() > 63 {
667 return Err(ValidationError::new(format!(
668 "domain label exceeds 63-octet limit \
669 (RFC 5321 Section 4.5.3.1.2 / RFC 6531 Section 3.3): {} octets",
670 label.len()
671 )));
672 }
673 if label.starts_with('-') || label.ends_with('-') {
676 return Err(ValidationError::new(
677 "domain labels must begin and end with a letter or digit \
678 (RFC 5321 Section 4.1.2 / RFC 6531 Section 3.3)",
679 ));
680 }
681 for ch in label.chars() {
682 if ch.is_ascii() && !(ch.is_ascii_alphanumeric() || ch == '-') {
683 return Err(ValidationError::new(format!(
684 "domain label contains invalid character {ch:?}; \
685 only letters, digits, '-', and UTF-8 U-label code points \
686 are permitted (RFC 5321 Section 4.1.2 / RFC 6531 Section 3.3)"
687 )));
688 }
689 }
690 }
691 Ok(())
692}
693
694fn validate_domain_label(label: &str) -> Result<(), ValidationError> {
696 if label.is_empty() {
697 return Err(ValidationError::new(
698 "domain contains an empty label (RFC 5321 Section 4.1.2)",
699 ));
700 }
701 if label.len() > 63 {
703 return Err(ValidationError::new(format!(
704 "domain label exceeds 63-octet limit \
705 (RFC 1035 Section 2.3.4 / RFC 5321 Section 4.1.2): {} octets",
706 label.len()
707 )));
708 }
709 let bytes = label.as_bytes();
710 if !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() {
712 return Err(ValidationError::new(
713 "domain labels must begin and end with a letter or digit \
714 (RFC 5321 Section 4.1.2)",
715 ));
716 }
717 for &b in bytes {
719 if !(b.is_ascii_alphanumeric() || b == b'-') {
720 return Err(ValidationError::new(format!(
721 "domain label contains invalid character {:?}; \
722 only letters, digits, and '-' are permitted (RFC 5321 Section 4.1.2)",
723 b as char
724 )));
725 }
726 }
727 Ok(())
728}
729
730fn validate_address_literal_syntax(literal: &str) -> Result<(), ValidationError> {
732 if literal.len() > 255 {
734 return Err(ValidationError::new(format!(
735 "address-literal exceeds 255-octet limit \
736 (RFC 5321 Section 4.5.3.1.2): {} octets",
737 literal.len()
738 )));
739 }
740
741 let Some(body) = literal.strip_prefix('[').and_then(|s| s.strip_suffix(']')) else {
742 return Err(ValidationError::new(
743 "address-literal must be enclosed in '[' and ']' (RFC 5321 Section 4.1.3)",
744 ));
745 };
746
747 if body.is_empty() {
748 return Err(ValidationError::new(
749 "address-literal must not be empty (RFC 5321 Section 4.1.3)",
750 ));
751 }
752
753 if body.parse::<Ipv4Addr>().is_ok() {
755 return Ok(());
756 }
757
758 if body.len() >= 5 && body[..5].eq_ignore_ascii_case("IPv6:") {
760 return body[5..].parse::<Ipv6Addr>().map(|_| ()).map_err(|_| {
761 ValidationError::new("invalid IPv6 address in address-literal (RFC 5321 Section 4.1.3)")
762 });
763 }
764
765 let Some((tag, value)) = body.split_once(':') else {
767 return Err(ValidationError::new(
768 "address-literal must be IPv4, IPv6, or generalized tag:value syntax \
769 (RFC 5321 Section 4.1.3)",
770 ));
771 };
772
773 validate_ldh_str(tag)?;
774
775 if value.is_empty() {
776 return Err(ValidationError::new(
777 "generalized address-literal must contain data after ':' \
778 (RFC 5321 Section 4.1.3)",
779 ));
780 }
781 for &b in value.as_bytes() {
782 if !((33..=90).contains(&b) || (94..=126).contains(&b)) {
784 return Err(ValidationError::new(format!(
785 "address-literal contains invalid data byte 0x{b:02X} \
786 (RFC 5321 Section 4.1.3)"
787 )));
788 }
789 }
790 Ok(())
791}
792
793fn validate_ldh_str(tag: &str) -> Result<(), ValidationError> {
795 if tag.is_empty() {
796 return Err(ValidationError::new(
797 "generalized address-literal tag must not be empty (RFC 5321 Section 4.1.3)",
798 ));
799 }
800 let bytes = tag.as_bytes();
801 if !bytes[bytes.len() - 1].is_ascii_alphanumeric() {
805 return Err(ValidationError::new(
806 "generalized address-literal tag must end with a letter or digit \
807 (RFC 5321 Section 4.1.3: Standardized-tag = Ldh-str)",
808 ));
809 }
810 for &b in bytes {
811 if !(b.is_ascii_alphanumeric() || b == b'-') {
812 return Err(ValidationError::new(format!(
813 "generalized address-literal tag contains invalid character {:?} \
814 (RFC 5321 Section 4.1.3)",
815 b as char
816 )));
817 }
818 }
819 Ok(())
820}
821
822fn strip_obsolete_source_route(address: &str) -> Result<&str, ValidationError> {
828 if !address.starts_with('@') {
829 return Ok(address);
830 }
831
832 let Some((route, mailbox)) = address.split_once(':') else {
833 return Err(ValidationError::new(
834 "obsolete source route must end with ':' before the mailbox \
835 (RFC 5321 Section 4.1.2)",
836 ));
837 };
838 if mailbox.is_empty() {
839 return Err(ValidationError::new(
840 "obsolete source route must be followed by a mailbox \
841 (RFC 5321 Section 4.1.2)",
842 ));
843 }
844
845 for at_domain in route.split(',') {
846 let Some(domain) = at_domain.strip_prefix('@') else {
847 return Err(ValidationError::new(
848 "obsolete source route entries must be @domain tokens \
849 (RFC 5321 Section 4.1.2)",
850 ));
851 };
852 validate_smtputf8_domain_syntax(domain)?;
853 }
854
855 Ok(mailbox)
856}
857
858fn validate_mailbox_syntax(address: &str) -> Result<(), ValidationError> {
860 if address.len() > 254 {
864 return Err(ValidationError::new(format!(
865 "mailbox exceeds 256-octet path limit including <> \
866 (RFC 5321 Section 4.5.3.1.3): {} octets",
867 address.len() + 2
868 )));
869 }
870
871 let address = strip_obsolete_source_route(address)?;
872
873 if address != address.trim() {
874 return Err(ValidationError::new(
875 "mailbox must not contain leading or trailing whitespace \
876 (RFC 5321 Section 4.1.2)",
877 ));
878 }
879
880 match address.parse::<daaki_message::Address>() {
882 Ok(parsed) if parsed.name.is_none() && parsed.email == address => {}
883 Ok(_) => {
884 return Err(ValidationError::new(
885 "mailbox must be a bare addr-spec, not a name-addr or comment form \
886 (RFC 5321 Section 4.1.2)",
887 ));
888 }
889 Err(err) => {
890 return Err(ValidationError::new(format!(
891 "mailbox is syntactically invalid: {err} (RFC 5321 Section 4.1.2)"
892 )));
893 }
894 }
895
896 if let Some(at_pos) = address.rfind('@') {
902 let local_part = &address[..at_pos];
903 let domain = &address[at_pos + 1..];
904 if local_part.starts_with('"') && local_part.ends_with('"') {
905 validate_smtp_quoted_local_part(local_part)?;
906 }
907 if domain.starts_with('[') && domain.ends_with(']') {
908 validate_address_literal_syntax(domain)?;
909 }
910
911 if local_part.len() > 64 {
913 return Err(ValidationError::new(format!(
914 "local-part exceeds 64-octet limit \
915 (RFC 5321 Section 4.5.3.1.1): {} octets",
916 local_part.len()
917 )));
918 }
919 }
920
921 Ok(())
922}
923
924fn validate_smtp_quoted_local_part(local_part: &str) -> Result<(), ValidationError> {
936 let Some(inner) = local_part
937 .strip_prefix('"')
938 .and_then(|value| value.strip_suffix('"'))
939 else {
940 return Err(ValidationError::new(
941 "quoted local-part must be enclosed in double quotes \
942 (RFC 5321 Section 4.1.2)",
943 ));
944 };
945
946 let bytes = inner.as_bytes();
947 let mut index = 0;
948 while index < bytes.len() {
949 let byte = bytes[index];
950 if byte == b'\\' {
951 index += 1;
952 if index >= bytes.len() {
953 return Err(ValidationError::new(
954 "quoted local-part has trailing backslash \
955 (RFC 5321 Section 4.1.2 quoted-pairSMTP)",
956 ));
957 }
958
959 let escaped = bytes[index];
960 if !(0x20..=0x7E).contains(&escaped) {
961 return Err(ValidationError::new(format!(
962 "quoted local-part contains invalid escaped byte 0x{escaped:02X}; \
963 quoted-pairSMTP permits only ASCII space or VCHAR \
964 (RFC 5321 Section 4.1.2)"
965 )));
966 }
967 } else if byte == b'"' || byte == b'\\' {
968 return Err(ValidationError::new(
969 "quoted local-part contains an unescaped quote or backslash \
970 (RFC 5321 Section 4.1.2 qtextSMTP)",
971 ));
972 } else if byte == b'\t' || byte < 0x20 || byte == 0x7F {
973 return Err(ValidationError::new(format!(
974 "quoted local-part contains control character 0x{byte:02X}; \
975 qtextSMTP permits ASCII space/graphics or UTF-8 non-ASCII only \
976 (RFC 5321 Section 4.1.2 / RFC 6531 Section 3.3)"
977 )));
978 }
979 index += 1;
980 }
981
982 Ok(())
983}
984
985#[cfg(test)]
986#[path = "validated_tests.rs"]
987mod tests;