From 6b91258ebe7225dd3419ce933be5474e7b08b691 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Fri, 2 Oct 2026 11:25:49 +0200 Subject: [PATCH] One set of mail variables for every mail, and {link} in state mails {site} and {site_name} everywhere; a state mail adds {self_link} (the submitter's own page, when their record's state allows a move there), {desk_link} and {link}, which picks by who the mail is to. gullberg's {link} mails went out blank until now. Documented in docs/mail.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi --- docs/SUMMARY.md | 1 + docs/mail.md | 59 +++++++++++++++++++++ docs/state-machines.md | 4 +- src/mail.rs | 114 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 docs/mail.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 294f957..3381590 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -7,6 +7,7 @@ - [Getting started](getting-started.md) - [Content reference](content-reference.md) - [State machines](state-machines.md) +- [Mail](mail.md) - [Access](access.md) - [Storylines](storylines.md) - [Memberships](memberships.md) diff --git a/docs/mail.md b/docs/mail.md new file mode 100644 index 0000000..f73b48c --- /dev/null +++ b/docs/mail.md @@ -0,0 +1,59 @@ +# Mail + +Portal writes every mail itself and hands it to gdo to deliver +(`portal.mail.send`); a reply comes back as a note beside the record +(`portal.mail.received`). A mail is a subject and a markdown body with +`{placeholders}`, sent as text with an HTML alternative. The kinds of +mail are the ones content declares on a state (see +[State machines](state-machines.md#mail)), the invite and its +rewording (`invite: { group, mail }`, `mail.invite` in site.yaml), the +note to a person named responsible for a question, and the answers to +a suggested change. + +## Variables + +A placeholder is `{` letters, digits, `_`, `-` or `.` `}`. One that +names nothing renders as nothing; a brace that opens no name stays a +brace. The variables come in layers, so a template written for one +kind of mail reads the same in another. + +**On every mail** + +| | | +|---|---| +| `{site}` | the instance's public URL, no trailing slash | +| `{site_name}` | site.yaml's `title` | + +**On a mail about a record** (every state mail) + +| | | +|---|---| +| `{field}` | any field of the record, as the form sent it | +| `{chain}` | the record's id | +| `{email}` | the record's `email` field, trimmed | +| `{bucket}`, `{state}` | where it is and the state it just entered | +| `{notice.field}` | a field of the record this one belongs to, through its `type: record` field `notice` | +| `{self_link}` | the record's own page for the person it belongs to: a page offering a self-service move (`self_transition`) on the bucket from the state the record is in, with the `?chain=&email=` that lets them act without signing in. Empty when there is no such page or no address | +| `{desk_link}` | the desk that moves the record: the page whose list over the bucket has `transitions`, else one that reads it. Empty when no page reads the bucket | +| `{link}` | where this reader acts: `{self_link}` in a mail `to: submitter`, `{desk_link}` in one to anyone else, and `{site}` when that link is empty | + +`{link}` is the one to reach for. "Se meldingen og avtal befaring: +{link}" sent to the caretaker opens the desk the report is on; "Feilen +du meldte om er utbedret. {link}" sent to the tenant opens their own +case where they can still act on it, or the site when there is +nothing left for them to do. + +**On an invite and the note to someone responsible**, beside every +field of the invite's form: `{name}`, `{username}`, `{email}`, +`{group}` (invite), `{questions}` (responsible), `{expires}`, and +`{link}`, which here is the one-time sign-in link. + +## What the lint holds a mail to + +A state mail that sends from a state no record reaches warns; one that +names a field no form recording into the bucket collects fails, since +it would go out with a blank where the field should be. The lint +knows every variable above, so `{link}` and the rest are never +flagged. `to: submitter` requires a required `email` on every form +filling the bucket, and `to: "{notice.email}"` requires the `notice` +field. diff --git a/docs/state-machines.md b/docs/state-machines.md index 67e49bf..cc7a622 100644 --- a/docs/state-machines.md +++ b/docs/state-machines.md @@ -57,7 +57,9 @@ a record arrives in it. ``` `{field}` is any field of the record, plus `{site}`, `{chain}`, -`{email}`, `{bucket}` and `{state}`. The body is markdown; it is sent +`{email}`, `{bucket}`, `{state}` and `{link}` - the page where the +reader acts on the record, their own or the desk's (every variable is +in [Mail](mail.md#variables)). The body is markdown; it is sent as text with an HTML alternative. `to: submitter` means the record's `email` field, and the lint then requires every form filling that bucket to collect a required `email`. diff --git a/src/mail.rs b/src/mail.rs index 44ad8a1..3476cba 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -251,6 +251,80 @@ pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Val vars } +/// What every mail portal sends may say, whatever made it - a state +/// mail, an invite, a suggestion's answer: `{site}` (the instance's +/// public URL) and `{site_name}` (site.yaml's title). One place, so a +/// template written for one kind of mail reads the same in another +/// (docs/mail.md, Variables). +pub fn site_vars(site: &crate::content::SiteConfig) -> serde_json::Map { + let mut vars = serde_json::Map::new(); + vars.insert("site".into(), public_url().into()); + vars.insert("site_name".into(), site_name(site).into()); + vars +} + +/// Where a mail about a record can send its reader (docs/mail.md): +/// +/// - `{self_link}`: the record's own page for the person it belongs +/// to - a page offering a self-service move on this bucket from the +/// state the record is in, with the `?chain=&email=` that +/// lets them act without signing in; +/// - `{desk_link}`: the desk that moves the record - the page whose +/// list over the bucket carries transitions, else one that reads it; +/// - `{link}`: `{self_link}` in a mail to the submitter, `{desk_link}` +/// in one to anyone else, and the site itself when there is neither. +/// +/// Empty strings, never missing keys, for a link the content has no +/// page for: a template naming one renders blank there rather than +/// with a broken URL. +pub fn record_links( + questions: &std::collections::HashMap, + initial: &str, + bucket: &str, + id: &str, + state: &str, + email: &str, + to_submitter: bool, +) -> serde_json::Map { + let mut pages: Vec<&crate::content::Question> = questions.values().collect(); + pages.sort_by(|a, b| a.id.cmp(&b.id)); + // Only a move the engine would accept from where the record is: + // a link to a button that refuses is worse than none. + let self_page = pages.iter().find(|q| { + q.alternatives + .iter() + .filter_map(|a| a.self_transition.as_ref()) + .any(|st| st.bucket == bucket && st.from_states(initial).iter().any(|f| f == state)) + }); + let reads = |q: &&crate::content::Question, moving: bool| { + q.alternatives + .iter() + .flat_map(|a| &a.features) + .filter_map(|f| f.resource.as_ref()) + .any(|r| r.bucket() == Some(bucket) && (!moving || !r.transitions.is_empty())) + }; + let desk_page = pages.iter().find(|q| reads(q, true)).or_else(|| pages.iter().find(|q| reads(q, false))); + let site = public_url(); + let self_link = match self_page { + Some(q) if email.contains('@') => { + let email: String = url::form_urlencoded::byte_serialize(email.as_bytes()).collect(); + format!("{site}{}?chain={id}&email={email}", q.id) + } + _ => String::new(), + }; + let desk_link = desk_page.map(|q| format!("{site}{}", q.id)).unwrap_or_default(); + let link = match (to_submitter, self_link.is_empty(), desk_link.is_empty()) { + (true, false, _) => self_link.clone(), + (false, _, false) => desk_link.clone(), + _ => site, + }; + let mut vars = serde_json::Map::new(); + vars.insert("self_link".into(), self_link.into()); + vars.insert("desk_link".into(), desk_link.into()); + vars.insert("link".into(), link.into()); + vars +} + /// The record field a `to` reaches through, when it is neither /// `submitter` nor an address: `{notice.email}` gives `notice`. pub fn parent_of(to: &str) -> Option<&str> { @@ -325,6 +399,13 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t }; let mut vars = vars_for(bucket, id, to_state, responses); parent_vars(state, bucket, responses, &mut vars).await; + vars.extend(site_vars(site)); + { + let email = vars.get("email").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let questions = state.questions.load(); + let initial = state.aggregates.load().at(storyline).get(bucket).map(|s| s.initial.clone()).unwrap_or_default(); + vars.extend(record_links(questions.at(storyline), &initial, bucket, id, to_state, &email, spec.to_submitter())); + } let to = if spec.to_submitter() { match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) { Some(e) => e.to_string(), @@ -427,9 +508,8 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe vars.insert("name".into(), invited.display_name.clone().into()); vars.insert("username".into(), invited.username.clone().into()); vars.insert("questions".into(), list.into()); + vars.extend(site_vars(&site)); vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into()); - vars.insert("site".into(), public_url().into()); - vars.insert("site_name".into(), site_name(&site).into()); vars.insert("expires".into(), expires.into()); let text = render(&body, &vars); let mail = Outgoing { @@ -501,7 +581,7 @@ async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state return; } let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::>().join("\n"); - let mut vars = serde_json::Map::new(); + let mut vars = site_vars(&site); vars.insert("name".into(), field("name").into()); vars.insert("question".into(), field("question").into()); vars.insert("page".into(), format!("{}{}", public_url(), field("page")).into()); @@ -617,9 +697,8 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people: vars.insert("username".into(), invited.username.clone().into()); vars.insert("email".into(), invited.email.clone().into()); vars.insert("group".into(), invited.group.clone().into()); + vars.extend(site_vars(&site)); vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into()); - vars.insert("site".into(), public_url().into()); - vars.insert("site_name".into(), site_name(&site).into()); vars.insert("expires".into(), expires.into()); let text = render(&body, &vars); let mail = Outgoing { @@ -640,6 +719,30 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people: #[cfg(test)] mod tests { + + #[test] + fn a_record_mail_links_its_reader_to_where_they_act() { + let files = vec![ + ("index.yaml".to_string(), "name: Meld fra\nalternatives:\n - name: Feil\n action: /takk\n record_as: feil\n features:\n - name: Feil\n requirements:\n - { name: email, type: email }\n".to_string()), + ("takk.yaml".to_string(), "name: Takk\nfollowup: true\n".to_string()), + ("trekk.yaml".to_string(), "name: Trekk saken\nalternatives:\n - name: Trekk\n self_transition: { bucket: feil, to: trukket, label: Trekk, from: [open, befaring] }\n".to_string()), + ("styret/index.yaml".to_string(), "name: Styret\nqualifies: styret\nalternatives:\n - name: Feil\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: feil }\n requires_group: styret\n transitions:\n - { to: befaring, label: Befaring }\n".to_string()), + ]; + let questions = crate::content::build_questions(&files).unwrap(); + let site = public_url(); + let to_tenant = record_links(&questions, "open", "feil", "abc", "befaring", "kari@x.no", true); + assert_eq!(to_tenant["self_link"], format!("{site}/trekk?chain=abc&email=kari%40x.no")); + assert_eq!(to_tenant["desk_link"], format!("{site}/styret")); + assert_eq!(to_tenant["link"], to_tenant["self_link"]); + let to_board = record_links(&questions, "open", "feil", "abc", "befaring", "kari@x.no", false); + assert_eq!(to_board["link"], to_board["desk_link"]); + // Past where the self-service move can be made: the person has + // no page of their own, so `{link}` is the site. + let after = record_links(&questions, "open", "feil", "abc", "utbedret", "kari@x.no", true); + assert_eq!(after["self_link"], ""); + assert_eq!(after["link"], site); + } + use super::*; #[test] @@ -743,6 +846,7 @@ mod tests { decided_ms: None, decided_by: None, notes: Vec::new(), + finished: false, } }