Skip to main content

systemprompt_identifiers/
email.rs

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