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
+1
View File
@@ -7,6 +7,7 @@
- [Getting started](getting-started.md) - [Getting started](getting-started.md)
- [Content reference](content-reference.md) - [Content reference](content-reference.md)
- [State machines](state-machines.md) - [State machines](state-machines.md)
- [Mail](mail.md)
- [Access](access.md) - [Access](access.md)
- [Storylines](storylines.md) - [Storylines](storylines.md)
- [Memberships](memberships.md) - [Memberships](memberships.md)
+59
View File
@@ -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.
+3 -1
View File
@@ -57,7 +57,9 @@ a record arrives in it.
``` ```
`{field}` is any field of the record, plus `{site}`, `{chain}`, `{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 as text with an HTML alternative. `to: submitter` means the record's
`email` field, and the lint then requires every form filling that `email` field, and the lint then requires every form filling that
bucket to collect a required `email`. bucket to collect a required `email`.
+109 -5
View File
@@ -251,6 +251,80 @@ pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Val
vars 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 /// The record field a `to` reaches through, when it is neither
/// `submitter` nor an address: `{notice.email}` gives `notice`. /// `submitter` nor an address: `{notice.email}` gives `notice`.
pub fn parent_of(to: &str) -> Option<&str> { 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); let mut vars = vars_for(bucket, id, to_state, responses);
parent_vars(state, bucket, responses, &mut vars).await; 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() { let to = if spec.to_submitter() {
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) { match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
Some(e) => e.to_string(), 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("name".into(), invited.display_name.clone().into());
vars.insert("username".into(), invited.username.clone().into()); vars.insert("username".into(), invited.username.clone().into());
vars.insert("questions".into(), list.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("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()); vars.insert("expires".into(), expires.into());
let text = render(&body, &vars); let text = render(&body, &vars);
let mail = Outgoing { let mail = Outgoing {
@@ -501,7 +581,7 @@ async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state
return; return;
} }
let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::<Vec<_>>().join("\n"); 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("name".into(), field("name").into());
vars.insert("question".into(), field("question").into()); vars.insert("question".into(), field("question").into());
vars.insert("page".into(), format!("{}{}", public_url(), field("page")).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("username".into(), invited.username.clone().into());
vars.insert("email".into(), invited.email.clone().into()); vars.insert("email".into(), invited.email.clone().into());
vars.insert("group".into(), invited.group.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("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()); vars.insert("expires".into(), expires.into());
let text = render(&body, &vars); let text = render(&body, &vars);
let mail = Outgoing { let mail = Outgoing {
@@ -640,6 +719,30 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
#[cfg(test)] #[cfg(test)]
mod tests { 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::*; use super::*;
#[test] #[test]
@@ -743,6 +846,7 @@ mod tests {
decided_ms: None, decided_ms: None,
decided_by: None, decided_by: None,
notes: Vec::new(), notes: Vec::new(),
finished: false,
} }
} }