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    #[expect(
91        clippy::expect_used,
92        reason = "infallible constructor reserved for already-validated inputs; untrusted input \
93                  must go through try_new"
94    )]
95    pub fn new(value: impl Into<String>) -> Self {
96        // SAFETY: `new` is the infallible constructor reserved for inputs the caller
97        // has already validated (compile-time literals, values that
98        // round-tripped through `try_new` at a boundary). Untrusted input must
99        // go through `try_new`.
100        Self::try_new(value).expect("Email validation failed")
101    }
102
103    #[must_use]
104    pub fn as_str(&self) -> &str {
105        &self.0
106    }
107
108    #[must_use]
109    pub fn local_part(&self) -> &str {
110        self.0.split('@').next().unwrap_or("")
111    }
112
113    #[must_use]
114    pub fn domain(&self) -> &str {
115        self.0.split('@').nth(1).unwrap_or("")
116    }
117}
118
119impl fmt::Display for Email {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        write!(f, "{}", self.0)
122    }
123}
124
125impl TryFrom<String> for Email {
126    type Error = IdValidationError;
127
128    fn try_from(s: String) -> Result<Self, Self::Error> {
129        Self::try_new(s)
130    }
131}
132
133impl TryFrom<&str> for Email {
134    type Error = IdValidationError;
135
136    fn try_from(s: &str) -> Result<Self, Self::Error> {
137        Self::try_new(s)
138    }
139}
140
141impl std::str::FromStr for Email {
142    type Err = IdValidationError;
143
144    fn from_str(s: &str) -> Result<Self, Self::Err> {
145        Self::try_new(s)
146    }
147}
148
149impl AsRef<str> for Email {
150    fn as_ref(&self) -> &str {
151        &self.0
152    }
153}
154
155impl<'de> Deserialize<'de> for Email {
156    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
157    where
158        D: serde::Deserializer<'de>,
159    {
160        let s = String::deserialize(deserializer)?;
161        Self::try_new(s).map_err(serde::de::Error::custom)
162    }
163}
164
165impl ToDbValue for Email {
166    fn to_db_value(&self) -> DbValue {
167        DbValue::String(self.0.clone())
168    }
169}
170
171impl ToDbValue for &Email {
172    fn to_db_value(&self) -> DbValue {
173        DbValue::String(self.0.clone())
174    }
175}