Skip to main content

logicaffeine_web/ui/pages/contact/
lead.rs

1//! The contact form's rules: what a valid email and phone are, the request the form sends, and
2//! what the worker's answer means.
3//!
4//! The worker behind [`ENDPOINT`] applies the same rules (`services/leads/src/lead.js`), and both
5//! walk one set of vectors (`crates/logicaffeine_tests/tests/fixtures/lead_vectors.json`), so the
6//! page never refuses what the worker would take, or sends what it would refuse.
7
8use serde_json::{json, Value};
9
10/// Where the form sends its request.
11pub const ENDPOINT: &str = "https://api.logicaffeine.com/lead";
12
13/// The form's tag in every request, so the team can tell where a request came from.
14/// The page's tag on the wire. It keeps its old name: the lead worker is deployed on it, and a lead
15/// from the contact page is the same lead.
16pub const SOURCE: &str = "pricing";
17
18/// The action the form's Turnstile widget stamps on its token; the worker's siteverify check
19/// refuses a token that carries any other.
20pub 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
29/// `value` without leading or trailing Unicode white space or byte-order marks. `str::trim`
30/// keeps U+FEFF, which a paste from some apps carries, so it is not the rule.
31pub 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    /// Every error the email field can show.
43    pub const ALL: [EmailError; 2] = [EmailError::Required, EmailError::Malformed];
44
45    /// The worker's name for it.
46    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    /// What the field says under it.
58    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    /// Every error the phone field can show.
77    pub const ALL: [PhoneError; 5] =
78        [PhoneError::BadCharacter, PhoneError::MisplacedPlus, PhoneError::TooLong, PhoneError::TooFewDigits, PhoneError::TooManyDigits];
79
80    /// The worker's name for it.
81    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    /// What the field says under it.
96    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
107/// A valid email: the trimmed local part as typed, `@`, the domain lower-cased.
108pub 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/// A phone as the worker stores it: the trimmed number as typed, and its digits alone.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct Phone {
142    pub normalized: String,
143    pub digits: String,
144}
145
146/// A phone is optional: nothing, or only white space, is no phone.
147pub 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/// What is wrong with each field; nothing when both are fine.
173#[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
185/// The JSON the worker reads, `{ email, phone, turnstile_token, source, website }`, or what is
186/// wrong with the fields. The honeypot (`website`) rides untouched: the worker decides what a
187/// filled one means.
188pub 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/// What the worker's answer means for the form.
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum Outcome {
204    /// Stored: the team has it.
205    Sent,
206    /// The worker refused a field: the send failed, and the form also says why under that field.
207    Invalid(FieldErrors),
208    /// Any other answer: the form offers the team's address instead.
209    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;