From 83410898985810a243268c5e60fad91fe9a5fbcd Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 23 Sep 2026 21:18:14 +0200 Subject: [PATCH] build: a plan says who hears when a case moves, and the words write the mail `mails: { : submitter |
}` on a need becomes `mail:` on that state in aggregates.yaml (portal 0.3.43+), and `mail: { from, reply_to }` on the plan becomes site.yaml's sender. The words map gains `aggregates.yaml`, one subject and body per mailing stage, so the model writes them like any other page; the fallback, in the plan's language, says what happened and where the submitter's own move is. A submitter mail needs a required email on the form; any mail needs a sender. The uhhm and Tomter Vel plans tell the submitter at every stage that matters to them. Co-Authored-By: Claude Fable 5.1 --- README.md | 10 +++ plans/tomtervel.yaml | 6 ++ plans/uhhm.yaml | 5 ++ src/build.rs | 142 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 158 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2e03c80..6f1d52f 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,16 @@ the business's own words and held-out personas. site that holds records keeps the names its aggregates.yaml has, or every record vanishes from its desk. `plans/uhhm.yaml` rebuilds uhhm.no this way. +- `mails: { : submitter |
}` on a need: who hears + when a case enters that stage, sent by portal the moment it moves + (portal 0.3.43, delivered by uhhm/gdo on the host). `submitter` goes + to the email the form collected, so the form must collect a required + `email`; an address is the treasurer, the board. The plan then needs + `mail: { from, reply_to }` at the top, the sender every mail carries + into `site.yaml`. The words model writes each mail's subject and + body (`aggregates.yaml` in the words map); the fallback says what + happened and, where the submitter has a move from that stage, the + link to make it. - `attended_by:` on a need: who moves cases that no desk button moves, an automation listening on the bucket's events, written into aggregates.yaml as portal's own annotation for the reader. diff --git a/plans/tomtervel.yaml b/plans/tomtervel.yaml index 041cd26..c3c4d48 100644 --- a/plans/tomtervel.yaml +++ b/plans/tomtervel.yaml @@ -9,6 +9,9 @@ business: Tomter Vel language: no contact: { name: Styret i Tomter Vel, email: post@tomtervel.no } +# Avsenderen for all e-post fra siden (gdo på kasse leverer); det +# kasses postfix kan sende for i dag. +mail: { from: "Tomter Vel ", reply_to: post@tomtervel.no } about: > Velforeningen for Tomter, stiftet i 1947. Sju komiteer drevet på fritida: arrangementer, elvestien, lekeplassene, høringssvar, @@ -59,6 +62,7 @@ needs: success: [betalt] # Medlemmet melder seg ut selv, gjennom sin egen lenke. moves_by: { utmeldt: [submitter, kasserer] } + mails: { open: submitter, betalt: submitter } max_fields: 3 collects: - { name: navn } @@ -166,6 +170,7 @@ needs: done_when: [ordnet, ikke_lost] success: [ordnet] moves_by: { ordnet: [submitter, nabohjelp] } + mails: { open: submitter, formidlet: submitter, ordnet: submitter } max_fields: 4 collects: - { name: navn } @@ -202,6 +207,7 @@ needs: # Den som la det ut kan ta det ned selv. Tavla er offentlig: det er # den naboene leser; navn og e-post vises ikke der. moves_by: { tatt_ned: [submitter, styret] } + mails: { lagt_ut: submitter } watched_by: [public] max_fields: 4 collects: diff --git a/plans/uhhm.yaml b/plans/uhhm.yaml index 5f58c8a..5d8eb4d 100644 --- a/plans/uhhm.yaml +++ b/plans/uhhm.yaml @@ -9,6 +9,8 @@ business: UHHM language: en contact: { name: Bendik Aagaard Lynghaug, email: bl@uhhm.no } +# Every mail the site sends comes from here (gdo on ergo delivers). +mail: { from: "UHHM ", reply_to: bl@uhhm.no } about: > UHHM builds software around a clear argument: infrastructure, tools and products for organizations that can say what problem they have. @@ -77,6 +79,7 @@ needs: events: { open: submitted } # The client plays too: they can withdraw the brief themselves. moves_by: { declined: [submitter, owners] } + mails: { open: submitter, accepted: submitter, declined: submitter } max_fields: 4 collects: - { name: name } @@ -95,6 +98,7 @@ needs: stages: [open, approved, previewed, declined] done_when: [previewed, declined] success: [previewed] + mails: { open: submitter, approved: submitter, previewed: submitter, declined: submitter } attended_by: > approved -> previewed: the n8n workflow that builds the draft site from an approved intake (docker, rauthy, portal content) @@ -171,6 +175,7 @@ needs: success: [resolved] # The person affected says when it is resolved, not only the custodian. moves_by: { resolved: [submitter, custodians] } + mails: { open: submitter, resolved: submitter, declined: submitter } max_fields: 4 collects: - { name: name } diff --git a/src/build.rs b/src/build.rs index b12d299..972f728 100644 --- a/src/build.rs +++ b/src/build.rs @@ -32,6 +32,10 @@ pub struct Plan { #[serde(default = "english")] pub language: String, pub contact: Contact, + /// The sender of every mail the site sends (`mail:` in site.yaml). + /// Required as soon as any need has `mails:`. + #[serde(default)] + pub mail: Option, /// What the business does, in its own words - context for the /// model's wording, never structure. #[serde(default)] @@ -134,6 +138,16 @@ pub struct Contact { pub email: String, } +/// Who the site's mail comes from: portal sends nothing without it, +/// and gdo on the host has to be allowed to send for the domain. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlanMail { + pub from: String, + #[serde(default)] + pub reply_to: Option, +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct PlanNeed { @@ -181,6 +195,12 @@ pub struct PlanNeed { /// for the reader, as portal's `attended_by` annotation. #[serde(default)] pub attended_by: Option, + /// Who hears when a case enters a stage: `submitter` (the person + /// who sent the form, at the email it collected) or one address + /// (the treasurer, the board). Portal sends it the moment the + /// case moves; the words model writes the subject and body. + #[serde(default)] + pub mails: BTreeMap, pub collects: Vec, /// How many required fields this person will tolerate: what the /// business said about them, not what the form happens to ask. @@ -329,6 +349,21 @@ impl Plan { bail!(at(&format!("{state:?} is not one of its stages"))); } } + for (state, to) in &need.mails { + if !need.stages.contains(state) { + bail!(at(&format!("mails names {state:?}, which is not one of its stages"))); + } + if to == SUBMITTER { + if !need.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional) { + bail!(at("a mail to the submitter goes to the email the form collected, so the form must collect a required `email` of type email")); + } + } else if !to.contains('@') || to.contains(char::is_whitespace) { + bail!(at(&format!("mails {state:?} must go to `submitter` or one address, not {to:?}"))); + } + if self.mail.is_none() { + bail!(at("has mails, so the plan needs `mail: { from: ... }` saying who they come from")); + } + } for (state, event) in &need.events { if !need.stages.contains(state) { bail!(at(&format!("events names {state:?}, which is not one of its stages"))); @@ -389,8 +424,9 @@ impl Plan { || other.handled_by != first.handled_by || other.events != first.events || other.attended_by != first.attended_by + || other.mails != first.mails { - bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by and who moves and watches it", first.id, other.id); + bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by, mails and who moves and watches it", first.id, other.id); } } } @@ -693,6 +729,58 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { ); } + // The mails: one subject and body per stage that sends one, keyed + // by bucket. Templates in portal's terms: {field} for a field of + // the record, {site}, {chain}, {email}. The fallback says what + // happened and, where the submitter has a move from that stage, + // where to make it. + let mut mails: Map = Map::new(); + for (bucket, needs) in plan.by_bucket() { + let need = needs[0]; + if need.mails.is_empty() { + continue; + } + // The fallback is true in the plan's language before a model + // has written a word: Norwegian or English, the two the plans + // come in. + let no = plan.language.starts_with("no") || plan.language.starts_with("nb") || plan.language.starts_with("nn"); + let name_field = need.collects.iter().find(|f| f.name == "name" || f.name == "navn").map(|f| f.name.clone()); + let greeting = match (&name_field, no) { + (Some(f), true) => format!("Hei {{{f}}},\n\n"), + (None, true) => "Hei,\n\n".to_string(), + (Some(f), false) => format!("Hello {{{f}}},\n\n"), + (None, false) => "Hello,\n\n".to_string(), + }; + let mut per_stage: Map = Map::new(); + for (stage, to) in &need.mails { + let stage_word = humanize(stage).to_lowercase(); + let what = match (stage == &need.stages[0], no) { + (true, true) => format!("Vi har mottatt det du sendte om {}.", need.wants), + (false, true) => format!("Det du sendte om {} er nå {stage_word}.", need.wants), + (true, false) => format!("We have received what you sent about {}.", need.wants), + (false, false) => format!("What you sent about {} is now {stage_word}.", need.wants), + }; + let mut body = if to == SUBMITTER { + format!("{greeting}{what}") + } else if no { + format!("En sak i {} er nå {stage_word}: {{chain}}.", humanize(bucket).to_lowercase()) + } else { + format!("A case in {} is now {stage_word}: {{chain}}.", humanize(bucket).to_lowercase()) + }; + if to == SUBMITTER && moves_of(need, SUBMITTER).iter().any(|(from, _)| from == stage) { + let lead = if no { "Det du kan gjøre nå" } else { "What you can do next" }; + body.push_str(&format!("\n\n{lead}: {{site}}/decide/{}?chain={{chain}}&email={{email}}", need.id)); + } + let at = if no { "på" } else { "at" }; + body.push_str(&format!("\n\n{} {at} {}", plan.contact.name, plan.contact.email)); + per_stage.insert(stage.clone(), json!({ "subject": format!("{}: {}", plan.business, humanize(stage)), "body": body })); + } + mails.insert(bucket.to_string(), Value::Object(per_stage)); + } + if !mails.is_empty() { + words.insert("aggregates.yaml".to_string(), Value::Object(mails)); + } + // The submitter's own page: reached by their private link, it // offers the moves that are theirs to make. for need in &plan.needs { @@ -938,8 +1026,16 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result = BTreeMap::new(); let responsible = json!({ "name": plan.contact.name, "contact": plan.contact.email }); - files.insert("site.yaml".into(), json!({ "title": plan.business, "lang": plan.language })); + let mut site = json!({ "title": plan.business, "lang": plan.language }); + if let Some(mail) = &plan.mail { + site["mail"] = json!({ "from": mail.from }); + if let Some(reply_to) = &mail.reply_to { + site["mail"]["reply_to"] = json!(reply_to); + } + } + files.insert("site.yaml".into(), site); + let mail_words = words.get("aggregates.yaml").cloned().unwrap_or(Value::Null); files.insert( "aggregates.yaml".into(), json!({ "aggregates": plan.by_bucket().into_iter().map(|(bucket, needs)| { @@ -947,7 +1043,14 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result>(), + "states": need.stages.iter().map(|st| { + let mut state = json!({ "event": event_for(need, st) }); + if let Some(to) = need.mails.get(st) { + let w = &mail_words[bucket][st]; + state["mail"] = json!({ "to": to, "subject": s(&w["subject"]), "body": s(&w["body"]) }); + } + (st.clone(), state) + }).collect::>(), "transitions": machine(need), }); if let Some(who) = &need.attended_by { @@ -1260,7 +1363,9 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result String { - let purpose = if path.contains("/decide/") { + let purpose = if path == "aggregates.yaml" { + "the mails the site sends. Under each bucket, each stage that sends one has a `subject` and a `body`: the mail a person gets the moment their case enters that stage, saying what happened and what, if anything, they can do now. Plain text, short, in the business's voice. Keep every `{...}` placeholder exactly as it is - `{site}`, `{chain}`, `{email}` and any field name are filled in by the site - and keep any line with a link in it." + } else if path.contains("/decide/") { "a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide." } else if path == "questions/index.yaml" { "the front door: the first page any visitor sees. `question` is the page's one question. It opens modestly: `opener` is the first card, what the business does in several features and no button at all - a stranger reads it and decides whether to trust the place, so say what is true and useful, not what to press. `shopping` is the second card, things a visitor may look at before asking for anything. Only then come the offers: under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor, and its heading must let that visitor recognise themselves from the heading alone; under `needs`, each entry is one alternative with a form behind it." @@ -1390,11 +1495,37 @@ mod tests { lands_in: "b".into(), handled_by: "g".into(), stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable), moves_by: BTreeMap::new(), watched_by: vec![], - events: BTreeMap::new(), attended_by: None, + events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), collects: vec![], max_fields: 3, } } + #[test] + fn a_stage_may_mail_the_submitter_or_an_address_and_the_words_fill_it() { + let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: \"B \", reply_to: i@b.example }\nneeds:\n - id: leak\n who: owners\n wants: a dry house\n lands_in: leaks\n handled_by: roofers\n stages: [open, fixed, dropped]\n done_when: [fixed, dropped]\n success: [fixed]\n moves_by: { dropped: [submitter, roofers] }\n mails: { open: submitter, fixed: submitter, dropped: board@b.example }\n max_fields: 2\n collects:\n - { name: name }\n - { name: email, type: email }\n"; + let plan: Plan = serde_yaml::from_str(yaml).unwrap(); + plan.validate().unwrap(); + let words = fallback_words(&plan); + let open_body = words["aggregates.yaml"]["leaks"]["open"]["body"].as_str().unwrap(); + // The submitter has a move from open, so the fallback says where. + assert!(open_body.starts_with("Hello {name},")); + assert!(open_body.contains("{site}/decide/leak?chain={chain}&email={email}")); + assert!(!words["aggregates.yaml"]["leaks"]["fixed"]["body"].as_str().unwrap().contains("/decide/")); + let files = render(&plan, &words).unwrap(); + let site: Value = serde_yaml::from_str(&files["site.yaml"]).unwrap(); + assert_eq!(site["mail"]["from"], "B "); + let agg: Value = serde_yaml::from_str(&files["aggregates.yaml"]).unwrap(); + let states = &agg["aggregates"][0]["states"]; + assert_eq!(states["open"]["mail"]["to"], "submitter"); + assert_eq!(states["dropped"]["mail"]["to"], "board@b.example"); + assert!(states["fixed"]["mail"]["subject"].as_str().unwrap().contains("Fixed")); + // A submitter mail needs the email; mails need a sender. + let no_email = yaml.replace(" - { name: email, type: email }\n", "").replace(" moves_by: { dropped: [submitter, roofers] }\n", ""); + assert!(serde_yaml::from_str::(&no_email).unwrap().validate().unwrap_err().to_string().contains("required `email`")); + let no_from = yaml.replace("mail: { from: \"B \", reply_to: i@b.example }\n", ""); + assert!(serde_yaml::from_str::(&no_from).unwrap().validate().unwrap_err().to_string().contains("mail: { from")); + } + #[test] fn a_live_sites_event_names_survive_a_rebuild() { let mut n = need(&["open", "invited", "active", "departed", "declined"], &["departed", "declined"], &["departed"], &[]); @@ -1698,6 +1829,7 @@ pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result Vec<(&'static str, String)> { const KEYS: &[&[&str]] = &[ + &["bucket"], &["record_as"], &["action"], &["name"],