An invite may carry its own mail #26

Merged
bl merged 1 commits from invite-mail into main 2026-10-01 16:06:14 +00:00
8 changed files with 69 additions and 2 deletions
+4
View File
@@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v<version>` tag; the first. A release is a Cargo.toml bump plus a `v<version>` tag; the
content repos pin the tag they run (see the end of this file). content repos pin the tag they run (see the end of this file).
## 0.6.15 (2026-10-01)
- An invite may carry its own mail: `invite: { group, mail: { subject, body } }`. Every field of the form is a placeholder besides `{name}`, `{link}`, `{expires}` and the rest, and a date and time (`type: datetime-local`) is written out in words ("tirsdag 6. oktober kl. 15:00"). The words and the form are kept with the invite, so sending it again sends the same mail. This is for a desk that invites someone to a conversation at an hour, not into a group for good.
## 0.6.14 (2026-10-01) ## 0.6.14 (2026-10-01)
- Less room above the wordmark: 1.75rem, 1.25rem on a narrow screen (it was 4rem). The space was kept for who is here, which now sits on the wordmark's own line, and on a tall, narrow screen it read as dead space. - Less room above the wordmark: 1.75rem, 1.25rem on a narrow screen (it was 4rem). The space was kept for who is here, which now sits on the wordmark's own line, and on a tall, narrow screen it read as dead space.
+4
View File
@@ -125,6 +125,10 @@ answer:
from: [open, incomplete] # defaults to the initial state alone from: [open, incomplete] # defaults to the initial state alone
label: Send them label: Send them
invite: { group: case_officers } # onboard someone into Kanidm invite: { group: case_officers } # onboard someone into Kanidm
# or with words of its own, using any field of the form:
# invite:
# group: guests
# mail: { subject: "...", body: "... {tidspunkt} ... {link} ..." }
``` ```
`self_transition.from` is what keeps a person in dialogue with their `self_transition.from` is what keeps a person in dialogue with their
+7 -1
View File
@@ -3082,10 +3082,16 @@ pub async fn submit_answer(
let Some((name, who)) = crate::people::who_for(&responses) else { let Some((name, who)) = crate::people::who_for(&responses) else {
return Err(ServerFnError::new("an invite needs a name, and a phone number or an email")); return Err(ServerFnError::new("an invite needs a name, and a phone number or an email"));
}; };
let invited = people let mut invited = people
.invite(&state, &name, &who, &invite.group, &principal_name(&principal)) .invite(&state, &name, &who, &invite.group, &principal_name(&principal))
.await .await
.map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?; .map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?;
// This invite's own words, and the form they draw on: kept with
// the invite, so sending it again says the same thing.
invited.mail = invite.mail.clone();
if invited.mail.is_some() {
invited.fields = responses.as_object().cloned().unwrap_or_default();
}
if let Some(map) = responses.as_object_mut() { if let Some(map) = responses.as_object_mut() {
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default()); map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
} }
+7
View File
@@ -391,6 +391,13 @@ pub struct Alternative {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Invite { pub struct Invite {
pub group: String, pub group: String,
/// This invite's own mail, in place of the site's (`mail.invite`):
/// an invitation to a conversation at an hour, say. Besides
/// `{name}`, `{link}`, `{expires}`, `{username}`, `{group}`,
/// `{site}` and `{site_name}`, every field of the form is a
/// placeholder; a date and time is written out in words.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mail: Option<MailTemplate>,
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
+35 -1
View File
@@ -416,6 +416,14 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()) .map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_default(); .unwrap_or_default();
let mut vars = serde_json::Map::new(); let mut vars = serde_json::Map::new();
// The form's own fields first, so the invite's fixed placeholders
// win a clash; a date and time reads as one.
for (key, value) in &invited.fields {
let text = value.as_str().map(|v| spoken_datetime(v, norwegian).unwrap_or_else(|| v.to_string()));
if let Some(text) = text {
vars.insert(key.clone(), text.into());
}
}
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());
@@ -553,6 +561,25 @@ pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &s
/// Built in, since portal knows everything about it; `mail.invite` in /// Built in, since portal knows everything about it; `mail.invite` in
/// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`, /// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`,
/// `{site}`, `{expires}`. /// `{site}`, `{expires}`.
/// A form's date and time (`2026-10-06T15:00`, from a `datetime-local`
/// field) as a person says it: "tirsdag 6. oktober kl. 15:00", "Tuesday
/// 6 October at 15:00". Anything else is left as it is.
pub fn spoken_datetime(value: &str, norwegian: bool) -> Option<String> {
use chrono::{Datelike, NaiveDateTime, Timelike};
let t = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M").ok()?;
let day = t.weekday().num_days_from_monday() as usize;
let month = t.month0() as usize;
Some(if norwegian {
const DAYS: [&str; 7] = ["mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag", "søndag"];
const MONTHS: [&str; 12] = ["januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember"];
format!("{} {}. {} kl. {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
} else {
const DAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
format!("{} {} {} at {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
})
}
pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people::Invited) { pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people::Invited) {
let site = state.site.load(); let site = state.site.load();
let Some(cfg) = &site.mail else { let Some(cfg) = &site.mail else {
@@ -560,7 +587,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
return; return;
}; };
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn")); let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
let (subject, body) = match (&cfg.invite, invited.reset_link.is_some(), norwegian) { let (subject, body) = match (invited.mail.as_ref().or(cfg.invite.as_ref()), invited.reset_link.is_some(), norwegian) {
(Some(t), _, _) => (t.subject.clone(), t.body.clone()), (Some(t), _, _) => (t.subject.clone(), t.body.clone()),
(None, true, true) => ( (None, true, true) => (
"Du er invitert inn i {group} på {site_name}".to_string(), "Du er invitert inn i {group} på {site_name}".to_string(),
@@ -662,6 +689,13 @@ mod tests {
assert_eq!(vars["state"], "fixed"); assert_eq!(vars["state"], "fixed");
} }
#[test]
fn a_date_and_time_from_a_form_reads_as_a_person_says_it() {
assert_eq!(spoken_datetime("2026-10-06T15:00", true).as_deref(), Some("tirsdag 6. oktober kl. 15:00"));
assert_eq!(spoken_datetime("2026-10-06T15:00", false).as_deref(), Some("Tuesday 6 October at 15:00"));
assert_eq!(spoken_datetime("Anders Kluge", true), None);
}
#[test] #[test]
fn the_html_alternative_keeps_markdown_and_drops_raw_html() { fn the_html_alternative_keeps_markdown_and_drops_raw_html() {
let html = to_html("Hello **there**\n\n<script>x()</script>\n\n[link](https://x.no)"); let html = to_html("Hello **there**\n\n<script>x()</script>\n\n[link](https://x.no)");
+2
View File
@@ -254,6 +254,8 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who
reset_link: None, reset_link: None,
link_expires_ms: None, link_expires_ms: None,
created, created,
mail: None,
fields: Default::default(),
already_member: !created, already_member: !created,
}; };
match existing { match existing {
+8
View File
@@ -127,6 +127,12 @@ pub struct Invited {
/// the membership is not portal's to take back. /// the membership is not portal's to take back.
#[serde(default)] #[serde(default)]
pub already_member: bool, pub already_member: bool,
/// The invite's own mail and the form it was sent from, kept with
/// the invite so that sending it again sends the same words.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mail: Option<crate::content::MailTemplate>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub fields: serde_json::Map<String, serde_json::Value>,
} }
/// When a link runs out, from Kanidm's `expiry_time`: seconds since /// When a link runs out, from Kanidm's `expiry_time`: seconds since
@@ -367,6 +373,8 @@ impl Kanidm {
reset_link, reset_link,
link_expires_ms, link_expires_ms,
created, created,
mail: None,
fields: Default::default(),
already_member, already_member,
}) })
} }
+2
View File
@@ -410,6 +410,8 @@ mod tests {
reset_link: None, reset_link: None,
link_expires_ms: None, link_expires_ms: None,
created: true, created: true,
mail: None,
fields: Default::default(),
already_member, already_member,
}, },
) )