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:
co-authored by
Claude Opus 5.5
parent
ae119af987
commit
6b91258ebe
+109
-5
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user