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=");
}
}