systemprompt_identifiers/
email.rs

1//! Email identifier type with validation.
2
3use 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}