//! What the site's mail promises, checked before it is sent to anyone. //! //! A mail is read alone, away from the page that caused it, by a //! person who cannot ask what a blank means. Portal renders a `{key}` //! it has no value for as nothing, silently, and sends a link to a //! page that is not there as readily as one that is. So the things a //! mail can get wrong without any runtime error are checked here: //! //! - **fail** a placeholder no record in the bucket can fill: the mail //! goes out with a hole in it; //! - **fail** a link to a page the site does not have; //! - **fail** a state that grants a group on a site that sends no mail: //! the account is made and its sign-in link reaches nobody; //! - **fail** an invite mail without its `{link}`, or with a //! placeholder the invite cannot fill; //! - **warn** a placeholder only some of the bucket's forms collect: it //! is blank in the mails about the others; //! - **warn** a dialogue that does not close: the person is mailed on //! the way in and not told how their case ended; //! - **warn** a mail that tells a person their case is where they may //! now act on it, and gives them no link to do so; //! - **warn** a long answer (a `textarea` field) put in a subject line, //! which becomes the whole brief in someone's inbox list. use std::collections::{BTreeMap, BTreeSet, HashMap}; use portal::aggregates::AggregateSchema; use portal::content::{self, Question, SiteConfig}; use crate::needs::Level; /// What portal fills in a case mail besides the record's own answers /// (`mail.rs`, `vars_for`). const CASE_VARS: &[&str] = &["email", "site", "chain", "bucket", "state"]; /// What portal fills in the invite mail (`mail.rs`, `on_invite`). const INVITE_VARS: &[&str] = &["name", "username", "email", "group", "link", "site", "site_name", "expires"]; #[derive(Debug)] pub struct Finding { pub level: Level, pub text: String, } /// `{key}` exactly as portal's renderer reads one: ASCII alphanumerics, /// `_`, `-` and `.`, closed by `}`. A brace that opens no key is text. pub fn placeholders(template: &str) -> Vec { let mut keys = Vec::new(); let mut rest = template; while let Some(start) = rest.find('{') { let after = &rest[start + 1..]; let len = after .char_indices() .take_while(|(_, c)| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) .last() .map(|(i, c)| i + c.len_utf8()) .unwrap_or(0); if len > 0 && after[len..].starts_with('}') { keys.push(after[..len].to_string()); rest = &after[len + 1..]; } else { rest = after; } } keys } /// The paths a template links to on its own site: `{site}/x/y?...`. fn site_links(template: &str) -> Vec { template .match_indices("{site}") .map(|(i, _)| { let tail = &template[i + "{site}".len()..]; let end = tail.find(|c: char| c.is_whitespace() || matches!(c, '?' | '#' | ')' | '>' | '"' | '\'')).unwrap_or(tail.len()); let path = tail[..end].trim_end_matches(['.', ',', ';', ':']); if path.is_empty() { "/".to_string() } else { path.to_string() } }) .collect() } fn page_exists(questions: &HashMap, path: &str) -> bool { let path = path.trim_end_matches('/'); let path = if path.is_empty() { "/" } else { path }; questions.contains_key(path) || questions.values().any(|q| q.is_dynamic() && content::path_matches(&q.id, path).is_some()) } /// For each bucket, the field names of every alternative that records /// into it: one set per form. fn forms_by_bucket(questions: &HashMap) -> BTreeMap)>> { let mut out: BTreeMap)>> = BTreeMap::new(); for q in questions.values() { for alt in &q.alternatives { if let Some(bucket) = &alt.record_as { let fields = alt.features.iter().flat_map(|f| &f.requirements).map(|r| r.name.clone()).collect(); out.entry(bucket.clone()).or_default().push((format!("{} / {}", q.id, alt.name), fields)); } } } out } /// States with no move out of them: where a case ends. fn endings(schema: &AggregateSchema) -> BTreeSet { let mut states: BTreeSet = schema.event_for_state.keys().cloned().collect(); states.insert(schema.initial.clone()); states.into_iter().filter(|s| schema.allowed(s).is_empty()).collect() } /// States reachable from `from` along declared moves, `from` included. fn reachable(schema: &AggregateSchema, from: &str) -> BTreeSet { let mut seen = BTreeSet::from([from.to_string()]); let mut todo = vec![from.to_string()]; while let Some(s) = todo.pop() { for next in schema.allowed(&s) { if seen.insert(next.clone()) { todo.push(next.clone()); } } } seen } pub fn check(questions: &HashMap, aggregates: &HashMap, site: &SiteConfig) -> Vec { let mut findings = Vec::new(); let mut fail = |text: String| findings.push(Finding { level: Level::Fail, text }); let forms = forms_by_bucket(questions); let mut warns = Vec::new(); // Where the submitter may act on their own record: bucket -> state // -> the labels of the moves they may make from there. let mut own_moves: BTreeMap>> = BTreeMap::new(); for q in questions.values() { for alt in &q.alternatives { if let Some(st) = &alt.self_transition { if let Some(schema) = aggregates.get(&st.bucket) { for from in st.from_states(&schema.initial) { own_moves.entry(st.bucket.clone()).or_default().entry(from).or_default().push(st.label.clone()); } } } } } let mut buckets: Vec<&AggregateSchema> = aggregates.values().collect(); buckets.sort_by(|a, b| a.bucket.cmp(&b.bucket)); for schema in buckets { let bucket = &schema.bucket; let bucket_forms = forms.get(bucket).cloned().unwrap_or_default(); let mut states: Vec<(&String, &portal::aggregates::MailSpec)> = schema.mail_for_state.iter().collect(); states.sort_by(|a, b| a.0.cmp(b.0)); for (state, mail) in &states { let at = format!("mail on {bucket}:{state}"); for key in placeholders(&mail.subject).into_iter().chain(placeholders(&mail.body)) { if CASE_VARS.contains(&key.as_str()) { continue; } let having: Vec<&String> = bucket_forms.iter().filter(|(_, f)| f.contains(&key)).map(|(n, _)| n).collect(); if having.is_empty() { fail(format!("{at}: {{{key}}} is not a field any form recording into {bucket:?} collects, nor one portal fills ({}) - it would be sent blank", CASE_VARS.join(", "))); } else if having.len() < bucket_forms.len() { let missing: Vec<&String> = bucket_forms.iter().filter(|(_, f)| !f.contains(&key)).map(|(n, _)| n).collect(); warns.push(format!("{at}: {{{key}}} is blank for records sent from {}", missing.iter().map(|n| format!("{n:?}")).collect::>().join(", "))); } } for key in placeholders(&mail.subject) { let long = questions.values().flat_map(|q| &q.alternatives).filter(|a| a.record_as.as_deref() == Some(bucket.as_str())).flat_map(|a| a.features.iter().flat_map(|f| &f.requirements)).any(|r| r.name == key && r.kind == "textarea"); if long { warns.push(format!("{at}: the subject carries {{{key}}}, a long answer (textarea) - the whole of it lands in the subject line")); } } for path in site_links(&mail.body).into_iter().chain(site_links(&mail.subject)) { if path.contains('{') { continue; // filled per record; the page is checked where the pattern is declared } if !page_exists(questions, &path) { fail(format!("{at}: links to {path:?}, which is not a page on this site")); } } } if !schema.grants_for_state.is_empty() && site.mail.is_none() { let mut granting: Vec<&String> = schema.grants_for_state.keys().collect(); granting.sort(); fail(format!("{bucket}: state(s) {granting:?} grant a group, and site.yaml has no `mail: {{ from }}` - the account would be made and its sign-in link sent to nobody")); } // The dialogue closes: a person mailed on the way in hears how // it ended, from every ending their case can still reach. let to_submitter: BTreeSet<&String> = states.iter().filter(|(_, m)| m.to_submitter()).map(|(s, _)| *s).collect(); if let Some(first) = to_submitter.iter().min_by_key(|s| (s.as_str() != schema.initial, s.to_string())) { let reach = reachable(schema, first); let silent: Vec = endings(schema).into_iter().filter(|e| reach.contains(e) && !to_submitter.contains(e)).collect(); if !silent.is_empty() { warns.push(format!("{bucket}: the person is mailed at {first:?} and not told when their case ends at {silent:?}")); } } // A mail telling a person their case is where they may act on // it carries the link that lets them. for (state, mail) in &states { if !mail.to_submitter() { continue; } if let Some(labels) = own_moves.get(bucket).and_then(|m| m.get(*state)) { if !mail.body.contains("{chain}") { warns.push(format!("mail on {bucket}:{state}: the person may now {:?} themselves, and the mail gives them no link (`{{chain}}`) to do it", labels)); } } } } if let Some(invite) = site.mail.as_ref().and_then(|m| m.invite.as_ref()) { let keys: Vec = placeholders(&invite.subject).into_iter().chain(placeholders(&invite.body)).collect(); for key in &keys { if !INVITE_VARS.contains(&key.as_str()) { fail(format!("site.yaml mail.invite: {{{key}}} is not something the invite mail knows ({}) - it would be sent blank", INVITE_VARS.join(", "))); } } if !invite.body.contains("{link}") { fail("site.yaml mail.invite: the body has no {link} - the person invited could never sign in".to_string()); } } findings.extend(warns.into_iter().map(|text| Finding { level: Level::Warn, text })); findings } /// How many mails the site declares, invite included. pub fn count(aggregates: &HashMap, site: &SiteConfig) -> usize { aggregates.values().map(|s| s.mail_for_state.len()).sum::() + usize::from(site.mail.as_ref().is_some_and(|m| m.invite.is_some())) } #[cfg(test)] mod tests { use super::*; #[test] fn placeholders_are_read_the_way_portal_renders_them() { assert_eq!(placeholders("Hi {name}, see {site}/decide/x?chain={chain}. {not a key} {}"), ["name", "site", "chain"]); assert_eq!(site_links("Open {site}/decide/trial?chain={chain}. Or {site}."), ["/decide/trial", "/"]); } fn site(yaml: &str) -> SiteConfig { serde_yaml::from_str(yaml).unwrap() } fn repo(aggregates: &str, pages: &[(&str, &str)]) -> (HashMap, HashMap) { let aggregates = portal::aggregates::parse_aggregates_yaml(aggregates).unwrap(); let questions = pages .iter() .map(|(id, yaml)| { let mut q: Question = serde_yaml::from_str(yaml).unwrap(); q.id = id.to_string(); (id.to_string(), q) }) .collect(); (questions, aggregates) } const AGG: &str = "aggregates:\n - bucket: trials\n initial: open\n states:\n open: { event: received, mail: { to: submitter, subject: \"Got it, {name}\", body: \"Withdraw at {site}/decide/trial?chain={chain}\" } }\n approved: { event: approved, mail: { to: submitter, subject: Yes, body: \"{business} is approved\" } }\n declined: { event: declined }\n withdrawn: { event: withdrawn }\n transitions:\n open: [approved, declined, withdrawn]\n"; const FORM: &str = "name: Q?\nalternatives:\n - name: Try it\n record_as: trials\n features:\n - name: f\n requirements:\n - { name: name }\n - { name: email, type: email }\n"; const DECIDE: &str = "name: Decide?\nalternatives:\n - name: Withdraw\n self_transition: { bucket: trials, to: withdrawn, label: Withdraw }\n"; #[test] fn a_blank_placeholder_a_dead_link_and_a_silent_ending_are_found() { let (q, a) = repo(AGG, &[("/", FORM), ("/decide/trial", DECIDE)]); let f = check(&q, &a, &site("mail: { from: x@y.no }\n")); let text = |lvl: Level| f.iter().filter(|x| x.level == lvl).map(|x| x.text.clone()).collect::>(); let fails = text(Level::Fail); assert_eq!(fails.len(), 1, "{fails:?}"); assert!(fails[0].contains("{business}")); let warns = text(Level::Warn); assert!(warns.iter().any(|w| w.contains("declined") && w.contains("withdrawn")), "{warns:?}"); let (q, a) = repo(AGG, &[("/", FORM)]); let f = check(&q, &a, &site("mail: { from: x@y.no }\n")); assert!(f.iter().any(|x| x.level == Level::Fail && x.text.contains("/decide/trial"))); } #[test] fn a_grant_needs_a_sender_and_an_invite_needs_its_link() { let agg = "aggregates:\n - bucket: b\n initial: open\n states:\n open: { event: received }\n in: { event: in, grants: members }\n transitions:\n open: [in]\n"; let (q, a) = repo(agg, &[("/", "name: Q?\nalternatives:\n - name: A\n record_as: b\n features:\n - name: f\n requirements:\n - { name: name }\n - { name: email, type: email }\n")]); assert!(check(&q, &a, &site("title: T\n")).iter().any(|x| x.level == Level::Fail && x.text.contains("sent to nobody"))); let f = check(&q, &a, &site("mail: { from: x@y.no, invite: { subject: \"Hi {name}\", body: \"Welcome to {site_name}, {nickname}\" } }\n")); assert!(f.iter().any(|x| x.text.contains("{nickname}"))); assert!(f.iter().any(|x| x.text.contains("no {link}"))); } #[test] fn a_long_answer_in_a_subject_line_is_found() { let agg = AGG.replace("Got it, {name}", "About {brief}"); let form = FORM.replace(" - { name: email, type: email }\n", " - { name: email, type: email }\n - { name: brief, type: textarea }\n"); let (q, a) = repo(&agg, &[("/", &form), ("/decide/trial", DECIDE)]); let f = check(&q, &a, &site("mail: { from: x@y.no }\n")); assert!(f.iter().any(|x| x.level == Level::Warn && x.text.contains("{brief}") && x.text.contains("subject")), "{f:?}"); } #[test] fn a_mail_about_a_case_the_person_may_act_on_links_to_it() { let agg = AGG.replace("Withdraw at {site}/decide/trial?chain={chain}", "We have it"); let (q, a) = repo(&agg, &[("/", FORM), ("/decide/trial", DECIDE)]); let f = check(&q, &a, &site("mail: { from: x@y.no }\n")); assert!(f.iter().any(|x| x.level == Level::Warn && x.text.contains("no link")), "{f:?}"); } }