build: a plan says who hears when a case moves, and the words write the mail

`mails: { <stage>: submitter | <address> }` 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 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-23 21:18:14 +02:00
co-authored by Claude Fable 5.1
parent f5f5132056
commit 8341089898
4 changed files with 158 additions and 5 deletions
+10
View File
@@ -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: { <stage>: submitter | <address> }` 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.
+6
View File
@@ -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 <tomtervel@klingenbergbygg.no>", 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:
+5
View File
@@ -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 <portal@uhhm.no>", 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 }
+137 -5
View File
@@ -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<PlanMail>,
/// 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<String>,
}
#[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<String>,
/// 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<String, String>,
pub collects: Vec<Field>,
/// 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<String, Value> {
);
}
// 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<String, Value> = 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<String, Value> = 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<String, Value>) -> anyhow::Result<BT
let mut files: BTreeMap<String, Value> = 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<String, Value>) -> anyhow::Result<BT
let mut aggregate = json!({
"bucket": bucket,
"initial": &need.stages[0],
"states": need.stages.iter().map(|st| (st.clone(), json!({ "event": event_for(need, st) }))).collect::<Map<_, _>>(),
"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::<Map<_, _>>(),
"transitions": machine(need),
});
if let Some(who) = &need.attended_by {
@@ -1260,7 +1363,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
}
fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> 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 <post@b.example>\", 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 <post@b.example>");
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::<Plan>(&no_email).unwrap().validate().unwrap_err().to_string().contains("required `email`"));
let no_from = yaml.replace("mail: { from: \"B <post@b.example>\", reply_to: i@b.example }\n", "");
assert!(serde_yaml::from_str::<Plan>(&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<BTreeMap<String, V
/// the rendered side) are never an identity.
fn identity(v: &serde_yaml::Value) -> Vec<(&'static str, String)> {
const KEYS: &[&[&str]] = &[
&["bucket"],
&["record_as"],
&["action"],
&["name"],