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