2026-09-28 18:18:38 +02:00
//! 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
2026-09-28 18:27:17 +02:00
//! 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.
2026-09-28 18:18:38 +02:00
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 < String > {
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 < String > {
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 < String , Question > , 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 < String , Question > ) -> BTreeMap < String , Vec < ( String , BTreeSet < String > ) >> {
let mut out : BTreeMap < String , Vec < ( String , BTreeSet < String > ) >> = 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 < String > {
let mut states : BTreeSet < String > = 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 < String > {
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 < String , Question > , aggregates : & HashMap < String , AggregateSchema > , site : & SiteConfig ) -> Vec < Finding > {
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 < String , BTreeMap < String , Vec < String >>> = 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 ::< Vec < _ >> (). join ( ", " )));
}
}
2026-09-28 18:27:17 +02:00
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" ));
}
}
2026-09-28 18:18:38 +02:00
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 < String > = 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 < String > = 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 < String , AggregateSchema > , site : & SiteConfig ) -> usize {
aggregates . values (). map ( | s | s . mail_for_state . len ()). sum ::< usize > ()
+ 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 < String , Question > , HashMap < String , AggregateSchema > ) {
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? \n alternatives: \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? \n alternatives: \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 ::< Vec < _ >> ();
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? \n alternatives: \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}" )));
}
2026-09-28 18:27:17 +02:00
#[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:?}" );
}
2026-09-28 18:18:38 +02:00
#[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:?}" );
}
}