logicaffeine_web/ui/pages/contact/
lead.rs1use serde_json::{json, Value};
9
10pub const ENDPOINT: &str = "https://api.logicaffeine.com/lead";
12
13pub const SOURCE: &str = "pricing";
17
18pub const TURNSTILE_ACTION: &str = "pricing-lead";
21
22const MAX_EMAIL: usize = 254;
23const MAX_LOCAL: usize = 64;
24const MAX_LABEL: usize = 63;
25const MAX_PHONE: usize = 32;
26const MIN_DIGITS: usize = 7;
27const MAX_DIGITS: usize = 15;
28
29pub fn trim_field(value: &str) -> &str {
32 value.trim_matches(|c: char| c.is_whitespace() || c == '\u{FEFF}')
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum EmailError {
37 Required,
38 Malformed,
39}
40
41impl EmailError {
42 pub const ALL: [EmailError; 2] = [EmailError::Required, EmailError::Malformed];
44
45 pub fn reason(self) -> &'static str {
47 match self {
48 EmailError::Required => "required",
49 EmailError::Malformed => "malformed",
50 }
51 }
52
53 fn from_reason(reason: &str) -> Option<Self> {
54 Self::ALL.into_iter().find(|e| e.reason() == reason)
55 }
56
57 pub fn message(self) -> &'static str {
59 match self {
60 EmailError::Required => "Enter your work email, so the team can reply.",
61 EmailError::Malformed => "Enter a whole email address, with an @ and a domain.",
62 }
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum PhoneError {
68 BadCharacter,
69 MisplacedPlus,
70 TooLong,
71 TooFewDigits,
72 TooManyDigits,
73}
74
75impl PhoneError {
76 pub const ALL: [PhoneError; 5] =
78 [PhoneError::BadCharacter, PhoneError::MisplacedPlus, PhoneError::TooLong, PhoneError::TooFewDigits, PhoneError::TooManyDigits];
79
80 pub fn reason(self) -> &'static str {
82 match self {
83 PhoneError::BadCharacter => "bad_character",
84 PhoneError::MisplacedPlus => "misplaced_plus",
85 PhoneError::TooLong => "too_long",
86 PhoneError::TooFewDigits => "too_few_digits",
87 PhoneError::TooManyDigits => "too_many_digits",
88 }
89 }
90
91 fn from_reason(reason: &str) -> Option<Self> {
92 Self::ALL.into_iter().find(|e| e.reason() == reason)
93 }
94
95 pub fn message(self) -> &'static str {
97 match self {
98 PhoneError::BadCharacter => "Use only digits, spaces, a plus sign, parentheses, hyphens and periods.",
99 PhoneError::MisplacedPlus => "Put the + only at the start of the number.",
100 PhoneError::TooLong => "Keep the phone number to 32 characters or fewer.",
101 PhoneError::TooFewDigits => "Enter the whole number: it needs at least 7 digits.",
102 PhoneError::TooManyDigits => "A phone number has at most 15 digits.",
103 }
104 }
105}
106
107pub fn validate_email(input: Option<&str>) -> Result<String, EmailError> {
109 let email = trim_field(input.ok_or(EmailError::Required)?);
110 if email.is_empty() {
111 return Err(EmailError::Required);
112 }
113 if email.len() > MAX_EMAIL || !email.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
114 return Err(EmailError::Malformed);
115 }
116 let (local, domain) = email.split_once('@').ok_or(EmailError::Malformed)?;
117 if domain.contains('@') {
118 return Err(EmailError::Malformed);
119 }
120 let atom = |a: &str| !a.is_empty() && a.bytes().all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+/=?^_`{|}~-".contains(&b));
121 if local.len() > MAX_LOCAL || !local.split('.').all(atom) {
122 return Err(EmailError::Malformed);
123 }
124 let label = |l: &str| {
125 let bytes = l.as_bytes();
126 (1..=MAX_LABEL).contains(&bytes.len())
127 && bytes.iter().all(|b| b.is_ascii_alphanumeric() || *b == b'-')
128 && bytes[0] != b'-'
129 && bytes[bytes.len() - 1] != b'-'
130 };
131 let labels: Vec<&str> = domain.split('.').collect();
132 let top = labels[labels.len() - 1];
133 if labels.len() < 2 || !labels.iter().all(|l| label(l)) || top.len() < 2 || top.bytes().all(|b| b.is_ascii_digit()) {
134 return Err(EmailError::Malformed);
135 }
136 Ok(format!("{local}@{}", domain.to_ascii_lowercase()))
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct Phone {
142 pub normalized: String,
143 pub digits: String,
144}
145
146pub fn validate_phone(input: Option<&str>) -> Result<Option<Phone>, PhoneError> {
148 let Some(input) = input else { return Ok(None) };
149 let phone = trim_field(input);
150 if phone.is_empty() {
151 return Ok(None);
152 }
153 if !phone.chars().all(|c| c.is_ascii_digit() || " +()-.".contains(c)) {
154 return Err(PhoneError::BadCharacter);
155 }
156 if phone.rfind('+').is_some_and(|at| at > 0) {
157 return Err(PhoneError::MisplacedPlus);
158 }
159 if phone.len() > MAX_PHONE {
160 return Err(PhoneError::TooLong);
161 }
162 let digits: String = phone.chars().filter(char::is_ascii_digit).collect();
163 if digits.len() < MIN_DIGITS {
164 return Err(PhoneError::TooFewDigits);
165 }
166 if digits.len() > MAX_DIGITS {
167 return Err(PhoneError::TooManyDigits);
168 }
169 Ok(Some(Phone { normalized: phone.to_string(), digits }))
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
174pub struct FieldErrors {
175 pub email: Option<EmailError>,
176 pub phone: Option<PhoneError>,
177}
178
179impl FieldErrors {
180 pub fn is_empty(&self) -> bool {
181 self.email.is_none() && self.phone.is_none()
182 }
183}
184
185pub fn request_body(email: &str, phone: &str, turnstile_token: &str, website: &str) -> Result<Value, FieldErrors> {
189 match (validate_email(Some(email)), validate_phone(Some(phone))) {
190 (Ok(email), Ok(phone)) => Ok(json!({
191 "email": email,
192 "phone": phone.map(|p| p.normalized),
193 "turnstile_token": turnstile_token,
194 "source": SOURCE,
195 "website": website,
196 })),
197 (email, phone) => Err(FieldErrors { email: email.err(), phone: phone.err() }),
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum Outcome {
204 Sent,
206 Invalid(FieldErrors),
208 Failed,
210}
211
212impl Outcome {
213 pub fn of(status: u16, body: &str) -> Outcome {
214 let answer: Value = serde_json::from_str(body).unwrap_or(Value::Null);
215 match status {
216 202 if answer["ok"] == true => Outcome::Sent,
217 400 => {
218 let reason = answer["reason"].as_str().unwrap_or("");
219 let errors = match answer["error"].as_str() {
220 Some("invalid_email") => EmailError::from_reason(reason).map(|e| FieldErrors { email: Some(e), phone: None }),
221 Some("invalid_phone") => PhoneError::from_reason(reason).map(|e| FieldErrors { email: None, phone: Some(e) }),
222 _ => None,
223 };
224 errors.map_or(Outcome::Failed, Outcome::Invalid)
225 }
226 _ => Outcome::Failed,
227 }
228 }
229}
230
231#[cfg(test)]
232#[path = "../../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/pages/contact/lead/tests.rs"]
233mod tests;