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