AkurAI Build
Menu

Public repository / fee5b6c2cde89fa6bf8dc51068af7336b56a88b8

AkurAI-Framework / crates/cli/src/forms.rs

Latest change d29368589bcb7f72e0b934c229aa12cf1d2361fb - OLI-188: cargo fmt so dependents' fmt --all passes by Ólafur Búi Ólafsson

//! Declarative form handlers — `[[forms]]` in `akurai.toml`.
//!
//! A `POST` to a configured form path validates the urlencoded body, drops
//! honeypot hits, applies a per-IP rate limit, stores the submission durably in
//! `data/forms.db`, optionally mails a notification through a local SMTP
//! server, and answers `303` (Post/Redirect/Get). Validation failures re-render
//! the form's page template with `form.errors` / `form.values` (status 422).

use crate::cmd_serve::{error_page, render_page_with};
use crate::i18n::I18nState;
use akurai_collections::{Collection, Field, FieldKind, Store};
use akurai_http::{form, Method, Reply, Request, Response};
use akurai_json::Value;
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::path::Path;
use std::sync::Mutex;
use std::time::{Duration, Instant};

const DEFAULT_MAX_LEN: usize = 5000;

#[derive(Clone, Copy, PartialEq, Debug)]
enum Kind {
    Text,
    Email,
    Tel,
    Url,
    Number,
}

struct FieldCfg {
    name: String,
    kind: Kind,
    required: bool,
    min: Option<i64>,
    max: Option<i64>,
}

struct FormCfg {
    name: String,
    path: String,
    success: String,
    template: String,
    layout: Option<String>,
    honeypot: Option<String>,
    rate_limit: usize,
    rate_window: u64,
    trust_proxy: bool,
    fields: Vec<FieldCfg>,
    collection: Collection,
    notify_to: Option<String>,
    notify_from: String,
    notify_subject: String,
    reply_to: Option<String>,
    smtp: String,
    test_mode: bool,
}

pub struct FormsState {
    forms: Vec<FormCfg>,
    store: Mutex<Store>,
    hits: Mutex<HashMap<(String, String), Vec<Instant>>>,
}

impl FormsState {
    /// Load `[[forms]]` from `<project>/akurai.toml`; `Ok(None)` when absent.
    pub fn load(project: &Path, data_dir: &Path) -> Result<Option<FormsState>, String> {
        let Ok(text) = std::fs::read_to_string(project.join("akurai.toml")) else {
            return Ok(None);
        };
        let doc = akurai_toml::parse(&text).map_err(|e| format!("akurai.toml: {e}"))?;
        Self::from_doc(&doc, data_dir)
    }

    fn from_doc(doc: &akurai_toml::Value, data_dir: &Path) -> Result<Option<FormsState>, String> {
        let Some(list) = doc.get("forms").and_then(|v| v.as_array()) else {
            return Ok(None);
        };
        let mut forms = Vec::new();
        for f in list {
            forms.push(parse_form(f)?);
        }
        if forms.is_empty() {
            return Ok(None);
        }
        let store = Store::open(data_dir.join("forms.db")).map_err(|e| format!("forms.db: {e}"))?;
        Ok(Some(FormsState {
            forms,
            store: Mutex::new(store),
            hits: Mutex::new(HashMap::new()),
        }))
    }

    pub fn count(&self) -> usize {
        self.forms.len()
    }

    /// Handle a `POST` to a configured form path; `None` for anything else.
    pub fn handle(&self, root: &Path, i18n: Option<&I18nState>, req: &Request) -> Option<Reply> {
        if req.method != Method::Post {
            return None;
        }
        let prefixed = i18n.filter(|_| crate::routes::locale_prefix(root));
        let (path, locale) = match prefixed {
            Some(s) => {
                let (loc, rest) =
                    crate::seo::strip_locale_prefix(&req.path, s.locales(), s.default_locale());
                (
                    rest,
                    Some(loc.unwrap_or_else(|| s.default_locale().to_string())),
                )
            }
            None => (req.path.as_str(), None),
        };
        let cfg = self.forms.iter().find(|f| f.path == path)?;
        Some(Reply::Response(self.submit(
            cfg,
            root,
            i18n,
            req,
            locale.as_deref(),
        )))
    }

    fn submit(
        &self,
        cfg: &FormCfg,
        root: &Path,
        i18n: Option<&I18nState>,
        req: &Request,
        locale: Option<&str>,
    ) -> Response {
        let success = match (locale, i18n) {
            (Some(l), Some(s)) if cfg.success.starts_with('/') => {
                crate::seo::localized_path(&cfg.success, l, s.default_locale())
            }
            _ => cfg.success.clone(),
        };
        let pairs = form::parse_urlencoded(&req.body_str());
        let redirect = |to: &str| Response::new(303).with_header("Location", to);

        if let Some(hp) = &cfg.honeypot {
            if !form::field(&pairs, hp).unwrap_or("").is_empty() {
                // Look successful to the bot; store and mail nothing.
                return redirect(&success);
            }
        }

        let lang = req
            .header("Cookie")
            .and_then(|c| crate::auth::parse_cookie(c, "akurai-lang"));
        let extras = i18n
            .map(|s| s.extras_for_lang(locale.or(lang.as_deref())))
            .unwrap_or_default();

        let values: Vec<(String, String)> = cfg
            .fields
            .iter()
            .map(|f| {
                let v = form::field(&pairs, &f.name)
                    .unwrap_or("")
                    .trim()
                    .to_string();
                (f.name.clone(), v)
            })
            .collect();

        if let Some(retry) = self.rate_limited(cfg, req).filter(|_| !cfg.test_mode) {
            let msg = message(&extras, "forms.error.rate_limited");
            return rerender(cfg, root, &extras, 429, &values, &[], &msg)
                .with_header("Retry-After", &retry.to_string());
        }

        let errors = validate(cfg, &values, &extras);
        if !errors.is_empty() {
            return rerender(cfg, root, &extras, 422, &values, &errors, "");
        }

        if cfg.test_mode {
            return redirect(&success);
        }

        let record = Value::Object(
            values
                .iter()
                .map(|(k, v)| (k.clone(), Value::Str(v.clone())))
                .collect(),
        );
        let saved = self
            .store
            .lock()
            .expect("forms store mutex poisoned")
            .create(&cfg.collection, record);
        if let Err(e) = saved {
            eprintln!("forms[{}]: could not store submission: {e}", cfg.name);
            let msg = message(&extras, "forms.error.generic");
            return rerender(cfg, root, &extras, 500, &values, &[], &msg);
        }

        if let Some(to) = &cfg.notify_to {
            let mail = build_mail(cfg, to, &values);
            let smtp = cfg.smtp.clone();
            let name = cfg.name.clone();
            std::thread::spawn(move || {
                if let Err(e) = send_mail(&smtp, &mail) {
                    eprintln!("forms[{name}]: notify failed: {e}");
                }
            });
        }
        redirect(&success)
    }

    /// `Some(retry_after_secs)` when this client is over the form's limit.
    fn rate_limited(&self, cfg: &FormCfg, req: &Request) -> Option<u64> {
        if cfg.rate_limit == 0 {
            return None;
        }
        let ip = client_ip(req, cfg.trust_proxy);
        let window = Duration::from_secs(cfg.rate_window);
        let now = Instant::now();
        let mut hits = self.hits.lock().expect("forms rate mutex poisoned");
        let list = hits.entry((cfg.name.clone(), ip)).or_default();
        list.retain(|t| now.duration_since(*t) < window);
        if list.len() >= cfg.rate_limit {
            let oldest = list[0];
            return Some(
                window
                    .saturating_sub(now.duration_since(oldest))
                    .as_secs()
                    .max(1),
            );
        }
        list.push(now);
        // Keep the table bounded: drop idle clients when it grows large.
        if hits.len() > 10_000 {
            hits.retain(|_, l| l.iter().any(|t| now.duration_since(*t) < window));
        }
        None
    }
}

fn client_ip(req: &Request, trust_proxy: bool) -> String {
    if trust_proxy {
        if let Some(xff) = req.header("X-Forwarded-For") {
            if let Some(first) = xff.split(',').next() {
                return first.trim().to_string();
            }
        }
    }
    req.peer.map(|p| p.ip().to_string()).unwrap_or_default()
}

fn parse_form(f: &akurai_toml::Value) -> Result<FormCfg, String> {
    let s = |k: &str| f.get(k).and_then(|v| v.as_str()).map(str::to_string);
    let name = s("name").ok_or("[[forms]] entry needs a `name`")?;
    if name.is_empty()
        || !name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
    {
        return Err(format!("form name `{name}` must be [A-Za-z0-9_-]"));
    }
    let path = s("path").unwrap_or_else(|| format!("/forms/{name}"));
    let mut fields = Vec::new();
    for fv in f.get("fields").and_then(|v| v.as_array()).unwrap_or(&[]) {
        let fname = fv
            .get("name")
            .and_then(|v| v.as_str())
            .ok_or_else(|| format!("form `{name}`: field needs a `name`"))?;
        let kind = match fv.get("type").and_then(|v| v.as_str()).unwrap_or("text") {
            "text" | "textarea" => Kind::Text,
            "email" => Kind::Email,
            "tel" => Kind::Tel,
            "url" => Kind::Url,
            "number" => Kind::Number,
            other => return Err(format!("form `{name}`: unknown field type `{other}`")),
        };
        fields.push(FieldCfg {
            name: fname.to_string(),
            kind,
            required: fv
                .get("required")
                .and_then(|v| v.as_bool())
                .unwrap_or(false),
            min: fv.get("min").and_then(|v| v.as_i64()),
            max: fv.get("max").and_then(|v| v.as_i64()),
        });
    }
    if fields.is_empty() {
        return Err(format!("form `{name}` declares no [[forms.fields]]"));
    }
    let honeypot = s("honeypot");
    if let Some(h) = &honeypot {
        if fields.iter().any(|f| &f.name == h) {
            return Err(format!(
                "form `{name}`: honeypot `{h}` collides with a field"
            ));
        }
    }
    let collection = Collection::new(
        format!("form_{name}"),
        fields
            .iter()
            .map(|f| {
                let fld = Field::new(f.name.clone(), FieldKind::Text);
                if f.required {
                    fld.required()
                } else {
                    fld
                }
            })
            .collect(),
    );
    let template = s("template").unwrap_or_else(|| {
        path.trim_matches('/')
            .rsplit('/')
            .next()
            .filter(|p| !p.is_empty())
            .unwrap_or("index")
            .to_string()
    });
    Ok(FormCfg {
        success: s("success").unwrap_or_else(|| format!("{path}?sent=1")),
        template,
        layout: s("layout"),
        honeypot,
        rate_limit: f
            .get("rate_limit")
            .and_then(|v| v.as_i64())
            .unwrap_or(5)
            .max(0) as usize,
        rate_window: f
            .get("rate_window")
            .and_then(|v| v.as_i64())
            .unwrap_or(600)
            .max(1) as u64,
        trust_proxy: f
            .get("trust_proxy")
            .and_then(|v| v.as_bool())
            .unwrap_or(false),
        notify_to: s("notify_to"),
        notify_from: s("notify_from").unwrap_or_else(|| "forms@localhost".into()),
        notify_subject: s("notify_subject").unwrap_or_else(|| format!("New {name} submission")),
        reply_to: s("reply_to"),
        smtp: s("smtp").unwrap_or_else(|| "127.0.0.1:25".into()),
        test_mode: f
            .get("test_mode")
            .and_then(|v| v.as_bool())
            .unwrap_or(false),
        name,
        path,
        fields,
        collection,
    })
}

// ── validation ─────────────────────────────────────────────────────────────

fn default_message(key: &str) -> &'static str {
    match key {
        "forms.error.required" => "This field is required.",
        "forms.error.email" => "Enter a valid email address.",
        "forms.error.tel" => "Enter a valid phone number.",
        "forms.error.url" => "Enter a valid http(s) URL.",
        "forms.error.number" => "Enter a number.",
        "forms.error.min" => "Too short or too small (minimum {n}).",
        "forms.error.max" => "Too long or too large (maximum {n}).",
        "forms.error.rate_limited" => "Too many submissions. Please try again later.",
        _ => "Something went wrong. Please try again.",
    }
}

/// Localised message from the `__messages` extras, else the English default.
fn message(extras: &[(String, Value)], key: &str) -> String {
    if let Some((_, Value::Object(msgs))) = extras.iter().find(|(k, _)| k == "__messages") {
        if let Some((_, Value::Str(s))) = msgs.iter().find(|(k, _)| k == key) {
            if s != key {
                return s.clone();
            }
        }
    }
    default_message(key).to_string()
}

fn validate(
    cfg: &FormCfg,
    values: &[(String, String)],
    extras: &[(String, Value)],
) -> Vec<(String, String)> {
    let mut errors = Vec::new();
    for (f, (_, v)) in cfg.fields.iter().zip(values) {
        if let Some(key) = check_field(f, v) {
            let n = match key {
                "forms.error.min" => f.min,
                _ => f.max.or(Some(DEFAULT_MAX_LEN as i64)),
            };
            let msg = message(extras, key).replace("{n}", &n.unwrap_or(0).to_string());
            errors.push((f.name.clone(), msg));
        }
    }
    errors
}

/// The message key of the first rule the value breaks, if any.
fn check_field(f: &FieldCfg, v: &str) -> Option<&'static str> {
    if v.is_empty() {
        return f.required.then_some("forms.error.required");
    }
    let len = v.chars().count() as i64;
    if len
        > f.max
            .filter(|_| f.kind != Kind::Number)
            .unwrap_or(DEFAULT_MAX_LEN as i64)
    {
        return Some("forms.error.max");
    }
    match f.kind {
        Kind::Email if !valid_email(v) => return Some("forms.error.email"),
        Kind::Tel if !valid_tel(v) => return Some("forms.error.tel"),
        Kind::Url if !(valid_url(v)) => return Some("forms.error.url"),
        Kind::Number => {
            let Ok(n) = v.parse::<f64>() else {
                return Some("forms.error.number");
            };
            if !n.is_finite() {
                return Some("forms.error.number");
            }
            if f.min.is_some_and(|m| n < m as f64) {
                return Some("forms.error.min");
            }
            if f.max.is_some_and(|m| n > m as f64) {
                return Some("forms.error.max");
            }
            return None;
        }
        _ => {}
    }
    if f.min.is_some_and(|m| len < m) {
        return Some("forms.error.min");
    }
    None
}

fn valid_email(v: &str) -> bool {
    if v.len() > 254 || v.chars().any(|c| c.is_whitespace() || c.is_control()) {
        return false;
    }
    let Some((local, domain)) = v.split_once('@') else {
        return false;
    };
    !local.is_empty()
        && local.len() <= 64
        && !domain.contains('@')
        && domain.contains('.')
        && !domain.starts_with('.')
        && !domain.ends_with('.')
        && !domain.contains("..")
}

fn valid_tel(v: &str) -> bool {
    let digits = v.chars().filter(char::is_ascii_digit).count();
    (5..=20).contains(&digits)
        && v.chars()
            .all(|c| c.is_ascii_digit() || " +-().".contains(c))
}

fn valid_url(v: &str) -> bool {
    let rest = v
        .strip_prefix("https://")
        .or_else(|| v.strip_prefix("http://"));
    rest.is_some_and(|r| {
        !r.is_empty() && !r.starts_with('/') && !v.chars().any(char::is_whitespace)
    })
}

// ── re-render ──────────────────────────────────────────────────────────────

fn rerender(
    cfg: &FormCfg,
    root: &Path,
    extras: &[(String, Value)],
    status: u16,
    values: &[(String, String)],
    errors: &[(String, String)],
    form_error: &str,
) -> Response {
    let obj = |pairs: &[(String, String)]| {
        Value::Object(
            pairs
                .iter()
                .map(|(k, v)| (k.clone(), Value::Str(v.clone())))
                .collect(),
        )
    };
    let form_ctx = Value::Object(vec![
        ("name".into(), Value::Str(cfg.name.clone())),
        ("error".into(), Value::Str(form_error.to_string())),
        ("errors".into(), obj(errors)),
        ("values".into(), obj(values)),
    ]);
    let mut extra = extras.to_vec();
    extra.push(("form".into(), form_ctx));
    let mut resp = render_page_with(
        root,
        &cfg.template,
        cfg.layout.as_deref(),
        None,
        &[],
        &extra,
    );
    if resp.status == 200 {
        resp.status = status;
    } else if resp.status == 500 && status != 500 {
        return Response::new(500).with_html(&error_page("form template failed to render"));
    }
    resp
}

// ── notification mail ──────────────────────────────────────────────────────

struct Mail {
    from: String,
    to: String,
    data: String,
}

fn strip_crlf(s: &str) -> String {
    s.chars().filter(|c| *c != '\r' && *c != '\n').collect()
}

fn base64(input: &[u8]) -> String {
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::new();
    for c in input.chunks(3) {
        let n = (c[0] as u32) << 16
            | (*c.get(1).unwrap_or(&0) as u32) << 8
            | *c.get(2).unwrap_or(&0) as u32;
        out.push(T[(n >> 18) as usize & 63] as char);
        out.push(T[(n >> 12) as usize & 63] as char);
        out.push(if c.len() > 1 {
            T[(n >> 6) as usize & 63] as char
        } else {
            '='
        });
        out.push(if c.len() > 2 {
            T[n as usize & 63] as char
        } else {
            '='
        });
    }
    out
}

fn wrap76(s: &str) -> String {
    s.as_bytes()
        .chunks(76)
        .map(|c| String::from_utf8_lossy(c).into_owned())
        .collect::<Vec<_>>()
        .join("\r\n")
}

fn build_mail(cfg: &FormCfg, to: &str, values: &[(String, String)]) -> Mail {
    let subject = strip_crlf(&cfg.notify_subject);
    let subject = if subject.is_ascii() {
        subject
    } else {
        format!("=?UTF-8?B?{}?=", base64(subject.as_bytes()))
    };
    let mut headers = format!(
        "From: {}\r\nTo: {}\r\nSubject: {subject}\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n",
        strip_crlf(&cfg.notify_from),
        strip_crlf(to),
    );
    if let Some(rt) = &cfg.reply_to {
        if let Some((_, v)) = values.iter().find(|(k, _)| k == rt) {
            if valid_email(v) {
                headers.push_str(&format!("Reply-To: {}\r\n", strip_crlf(v)));
            }
        }
    }
    let body: String = values.iter().map(|(k, v)| format!("{k}: {v}\n")).collect();
    Mail {
        from: strip_crlf(&cfg.notify_from),
        to: strip_crlf(to),
        data: format!("{headers}\r\n{}\r\n", wrap76(&base64(body.as_bytes()))),
    }
}

fn address_only(s: &str) -> &str {
    match (s.rfind('<'), s.rfind('>')) {
        (Some(a), Some(b)) if a < b => &s[a + 1..b],
        _ => s,
    }
}

fn send_mail(smtp: &str, mail: &Mail) -> Result<(), String> {
    let addr = smtp
        .to_socket_addrs()
        .map_err(|e| e.to_string())?
        .next()
        .ok_or("smtp address did not resolve")?;
    let timeout = Duration::from_secs(10);
    let stream = TcpStream::connect_timeout(&addr, timeout).map_err(|e| e.to_string())?;
    stream.set_read_timeout(Some(timeout)).ok();
    stream.set_write_timeout(Some(timeout)).ok();
    let mut reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?);
    let mut w = stream;

    let mut reply = |expect: char| -> Result<(), String> {
        loop {
            let mut line = String::new();
            reader.read_line(&mut line).map_err(|e| e.to_string())?;
            if line.len() < 4 {
                return Err(format!("short SMTP reply: {line:?}"));
            }
            if line.as_bytes()[3] == b'-' {
                continue;
            }
            return if line.starts_with(expect) {
                Ok(())
            } else {
                Err(format!("SMTP: {}", line.trim()))
            };
        }
    };
    let send = |w: &mut TcpStream, s: &str| w.write_all(s.as_bytes()).map_err(|e| e.to_string());

    reply('2')?;
    send(&mut w, "EHLO localhost\r\n")?;
    reply('2')?;
    send(
        &mut w,
        &format!("MAIL FROM:<{}>\r\n", address_only(&mail.from)),
    )?;
    reply('2')?;
    send(&mut w, &format!("RCPT TO:<{}>\r\n", address_only(&mail.to)))?;
    reply('2')?;
    send(&mut w, "DATA\r\n")?;
    reply('3')?;
    // Base64 body + fixed headers never start a line with "."; no dot-stuffing needed.
    send(&mut w, &format!("{}.\r\n", mail.data))?;
    reply('2')?;
    let _ = send(&mut w, "QUIT\r\n");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::net::TcpListener;
    use std::sync::mpsc;

    fn fixture(extra: &str) -> (FormsState, std::path::PathBuf) {
        let dir = std::env::temp_dir().join(format!(
            "akurai-forms-{}-{:?}",
            std::process::id(),
            std::thread::current().id()
        ));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let toml = format!(
            "[[forms]]\nname = \"contact\"\npath = \"/contact\"\nsuccess = \"/contact?sent=1\"\nhoneypot = \"website\"\nrate_limit = 2\n{extra}\n[[forms.fields]]\nname = \"name\"\nrequired = true\nmax = 10\n[[forms.fields]]\nname = \"email\"\ntype = \"email\"\nrequired = true\n[[forms.fields]]\nname = \"age\"\ntype = \"number\"\nmin = 18\n"
        );
        let doc = akurai_toml::parse(&toml).unwrap();
        (FormsState::from_doc(&doc, &dir).unwrap().unwrap(), dir)
    }

    fn post(body: &str) -> Request {
        let mut r = Request::parse_head(b"POST /contact HTTP/1.1\r\nHost: x\r\n\r\n").unwrap();
        r.body = body.as_bytes().to_vec();
        r.peer = Some("10.0.0.1:1234".parse().unwrap());
        r
    }

    fn resp(r: Reply) -> Response {
        match r {
            Reply::Response(r) => r,
            _ => panic!("expected response"),
        }
    }

    fn count(st: &FormsState) -> usize {
        st.store
            .lock()
            .unwrap()
            .list(&st.forms[0].collection, None)
            .unwrap()
            .len()
    }

    #[test]
    fn valid_submission_stores_and_redirects() {
        let (st, dir) = fixture("");
        let r = resp(
            st.handle(&dir, None, &post("name=Ada&email=ada%40example.com&age=30"))
                .unwrap(),
        );
        assert_eq!(r.status, 303);
        assert_eq!(count(&st), 1);
    }

    #[test]
    fn invalid_submission_is_422_and_not_stored() {
        let (st, dir) = fixture("");
        std::fs::write(
            dir.join("contact.html"),
            "{{ form.errors.email }}|{{ form.values.name }}",
        )
        .unwrap();
        let r = resp(
            st.handle(&dir, None, &post("name=Ada&email=nope&age=3"))
                .unwrap(),
        );
        assert_eq!(r.status, 422);
        let html = String::from_utf8_lossy(&r.body).into_owned();
        assert!(html.contains("Enter a valid email address."), "{html}");
        assert!(html.contains("Ada"));
        assert_eq!(count(&st), 0);
    }

    #[test]
    fn honeypot_redirects_without_storing() {
        let (st, dir) = fixture("");
        let r = resp(
            st.handle(
                &dir,
                None,
                &post("name=Bot&email=b%40x.io&website=http%3A%2F%2Fspam"),
            )
            .unwrap(),
        );
        assert_eq!(r.status, 303);
        assert_eq!(count(&st), 0);
    }

    #[test]
    fn rate_limit_blocks_third_submission() {
        let (st, dir) = fixture("");
        std::fs::write(dir.join("contact.html"), "x").unwrap();
        let body = "name=Ada&email=ada%40example.com";
        assert_eq!(
            resp(st.handle(&dir, None, &post(body)).unwrap()).status,
            303
        );
        assert_eq!(
            resp(st.handle(&dir, None, &post(body)).unwrap()).status,
            303
        );
        let r = resp(st.handle(&dir, None, &post(body)).unwrap());
        assert_eq!(r.status, 429);
        assert!(r
            .headers
            .iter()
            .any(|(k, _)| k.eq_ignore_ascii_case("Retry-After")));
        assert_eq!(count(&st), 2);
    }

    #[test]
    fn non_post_and_other_paths_fall_through() {
        let (st, dir) = fixture("");
        let mut r = post("");
        r.path = "/other".into();
        assert!(st.handle(&dir, None, &r).is_none());
    }

    #[test]
    fn notify_speaks_smtp_to_local_server() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();
        let (tx, rx) = mpsc::channel();
        std::thread::spawn(move || {
            let (s, _) = listener.accept().unwrap();
            let mut w = s.try_clone().unwrap();
            let mut r = BufReader::new(s);
            w.write_all(b"220 hi\r\n").unwrap();
            let mut log = String::new();
            let mut in_data = false;
            loop {
                let mut line = String::new();
                if r.read_line(&mut line).unwrap() == 0 {
                    break;
                }
                log.push_str(&line);
                if in_data {
                    if line == ".\r\n" {
                        in_data = false;
                        w.write_all(b"250 queued\r\n").unwrap();
                    }
                    continue;
                }
                if line.starts_with("DATA") {
                    in_data = true;
                    w.write_all(b"354 go\r\n").unwrap();
                } else if line.starts_with("QUIT") {
                    w.write_all(b"221 bye\r\n").unwrap();
                    break;
                } else {
                    w.write_all(b"250 ok\r\n").unwrap();
                }
            }
            tx.send(log).unwrap();
        });
        let (st, dir) = fixture(&format!(
            "notify_to = \"olibuijr@olibuijr.com\"\nnotify_from = \"Site <forms@olibuijr.com>\"\nreply_to = \"email\"\nsmtp = \"{addr}\"\n"
        ));
        let r = resp(
            st.handle(&dir, None, &post("name=Ada&email=ada%40example.com"))
                .unwrap(),
        );
        assert_eq!(r.status, 303);
        let log = rx.recv_timeout(Duration::from_secs(5)).unwrap();
        assert!(log.contains("MAIL FROM:<forms@olibuijr.com>"), "{log}");
        assert!(log.contains("RCPT TO:<olibuijr@olibuijr.com>"), "{log}");
        assert!(log.contains("Reply-To: ada@example.com"), "{log}");
    }

    #[test]
    fn test_mode_validates_but_stores_nothing_and_ignores_rate_limit() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();
        listener.set_nonblocking(true).unwrap();
        let (st, dir) = fixture(&format!(
            "test_mode = true\nnotify_to = \"olibuijr@olibuijr.com\"\nsmtp = \"{addr}\"\n"
        ));
        std::fs::write(dir.join("contact.html"), "{{ form.errors.email }}").unwrap();
        for _ in 0..5 {
            let r = resp(
                st.handle(&dir, None, &post("name=Ada&email=ada%40example.com"))
                    .unwrap(),
            );
            assert_eq!(r.status, 303);
        }
        let bad = resp(st.handle(&dir, None, &post("name=Ada&email=nope")).unwrap());
        assert_eq!(bad.status, 422);
        assert_eq!(count(&st), 0);
        std::thread::sleep(Duration::from_millis(200));
        assert!(
            listener.accept().is_err(),
            "test mode must not open an SMTP connection"
        );
    }

    #[test]
    fn validators() {
        assert!(valid_email("a@b.co"));
        assert!(!valid_email("a@b"));
        assert!(!valid_email("a b@c.io"));
        assert!(valid_tel("+354 555 1234"));
        assert!(!valid_tel("abc"));
        assert!(valid_url("https://akurai.olibuijr.com/x"));
        assert!(!valid_url("javascript:alert(1)"));
        assert_eq!(base64(b"Ma"), "TWE=");
    }
}