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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi
This commit is contained in:
Bendik Aagaard Lynghaug
2026-10-02 11:25:49 +02:00
co-authored by Claude Opus 5.5
parent ae119af987
commit 6b91258ebe
4 changed files with 172 additions and 6 deletions
+109 -5
View File
@@ -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<String, serde_json::Value> {
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<String, crate::content::Question>,
initial: &str,
bucket: &str,
id: &str,
state: &str,
email: &str,
to_submitter: bool,
) -> serde_json::Map<String, serde_json::Value> {
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::<Vec<_>>().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,
}
}