systemprompt_identifiers/
email.rs1use crate::error::IdValidationError;
4use crate::{DbValue, ToDbValue};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)]
10#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
11#[cfg_attr(feature = "sqlx", sqlx(transparent))]
12#[serde(transparent)]
13pub struct Email(String);
14
15impl Email {
16 pub fn try_new(value: impl Into<String>) -> Result<Self, IdValidationError> {
17 let value = value.into();
18 if value.is_empty() {
19 return Err(IdValidationError::empty("Email"));
20 }
21 let parts: Vec<&str> = value.split('@').collect();
22 if parts.len() != 2 {
23 return Err(IdValidationError::invalid(
24 "Email",
25 "must contain exactly one '@' symbol",
26 ));
27 }
28 let local = parts[0];
29 let domain = parts[1];
30 if local.is_empty() {
31 return Err(IdValidationError::invalid(
32 "Email",
33 "local part (before @) cannot be empty",
34 ));
35 }
36 if local.starts_with('.') || local.ends_with('.') {
37 return Err(IdValidationError::invalid(
38 "Email",
39 "local part cannot start or end with '.'",
40 ));
41 }
42 if local.contains("..") {
43 return Err(IdValidationError::invalid(
44 "Email",
45 "local part cannot contain consecutive dots",
46 ));
47 }
48 if local.contains('\n') || local.contains('\r') {
49 return Err(IdValidationError::invalid(
50 "Email",
51 "email cannot contain newline characters",
52 ));
53 }
54 if domain.is_empty() {
55 return Err(IdValidationError::invalid(
56 "Email",
57 "domain part (after @) cannot be empty",
58 ));
59 }
60 if !domain.contains('.') {
61 return Err(IdValidationError::invalid(
62 "Email",
63 "domain must contain at least one '.'",
64 ));
65 }
66 if domain.starts_with('.') || domain.ends_with('.') {
67 return Err(IdValidationError::invalid(
68 "Email",
69 "domain cannot start or end with '.'",
70 ));
71 }
72 if domain.contains("..") {
73 return Err(IdValidationError::invalid(
74 "Email",
75 "domain cannot contain consecutive dots",
76 ));
77 }
78 if let Some(tld) = domain.rsplit('.').next() {
79 if tld.len() < 2 {
80 return Err(IdValidationError::invalid(
81 "Email",
82 "TLD must be at least 2 characters",
83 ));
84 }
85 }
86 Ok(Self(value))
87 }
88
89 #[must_use]
90 #[allow(clippy::expect_used)]
91 pub fn new(value: impl Into<String>) -> Self {
92 Self::try_new(value).expect("Email validation failed")
93 }
94
95 #[must_use]
96 pub fn as_str(&self) -> &str {
97 &self.0
98 }
99
100 #[must_use]
101 pub fn local_part(&self) -> &str {
102 self.0.split('@').next().unwrap_or("")
103 }
104
105 #[must_use]
106 pub fn domain(&self) -> &str {
107 self.0.split('@').nth(1).unwrap_or("")
108 }
109}
110
111impl fmt::Display for Email {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 write!(f, "{}", self.0)
114 }
115}
116
117impl TryFrom<String> for Email {
118 type Error = IdValidationError;
119
120 fn try_from(s: String) -> Result<Self, Self::Error> {
121 Self::try_new(s)
122 }
123}
124
125impl TryFrom<&str> for Email {
126 type Error = IdValidationError;
127
128 fn try_from(s: &str) -> Result<Self, Self::Error> {
129 Self::try_new(s)
130 }
131}
132
133impl std::str::FromStr for Email {
134 type Err = IdValidationError;
135
136 fn from_str(s: &str) -> Result<Self, Self::Err> {
137 Self::try_new(s)
138 }
139}
140
141impl AsRef<str> for Email {
142 fn as_ref(&self) -> &str {
143 &self.0
144 }
145}
146
147impl<'de> Deserialize<'de> for Email {
148 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149 where
150 D: serde::Deserializer<'de>,
151 {
152 let s = String::deserialize(deserializer)?;
153 Self::try_new(s).map_err(serde::de::Error::custom)
154 }
155}
156
157impl ToDbValue for Email {
158 fn to_db_value(&self) -> DbValue {
159 DbValue::String(self.0.clone())
160 }
161}
162
163impl ToDbValue for &Email {
164 fn to_db_value(&self) -> DbValue {
165 DbValue::String(self.0.clone())
166 }
167}