//! Portal's side of the mail gate. Content decides when a person //! hears something (`mail:` on a state in aggregates.yaml, and the //! built-in invite mail); portal renders the message the moment a //! record enters that state and publishes it on one JetStream subject. //! gdo (uhhm/gdo) holds a durable consumer on that subject and hands //! each message to the host's SMTP. Portal never talks SMTP, gdo never //! reads content, and a message survives either of them being down. //! //! The message is the contract between the two, `Outgoing` below, //! versioned by `v`. Its id is the JetStream message id, so a //! transition that fires twice (a retry, a reseed) sends once. #![cfg(feature = "ssr")] use serde::{Deserialize, Serialize}; use std::time::Duration; /// The stream both sides create on boot, with the same config, so /// whichever starts first owns nothing the other lacks. pub const STREAM: &str = "mail"; pub const SUBJECTS: &str = "portal.mail.>"; pub const SUBJECT: &str = "portal.mail.send"; /// Where a reply to a case mail arrives, published by whatever reads /// the mailbox (uhhm/gdo over JMAP). Portal turns it into a note on /// the record it names and nothing else: no state moves on a reply. pub const RECEIVED_SUBJECT: &str = "portal.mail.received"; /// Bumped when `Outgoing` changes shape; gdo refuses what it does not /// know rather than guessing. pub const CONTRACT: u8 = 1; /// One mail, fully rendered: everything gdo needs and nothing it has /// to look up. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Outgoing { pub v: u8, /// `::` or `invite:::` - /// the dedup key. pub id: String, pub site: String, pub from: String, #[serde(default)] pub reply_to: Option, pub to: String, pub subject: String, pub text: String, pub html: String, } /// A reply that came back, already matched to a record by whatever /// read the mailbox. Portal trusts the match no further than it can /// check it: the record must exist, the bucket must be one this site /// has, and the sender must be the address the record itself holds. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Incoming { pub v: u8, /// The mail's own id, so the same reply read twice lands once. pub id: String, pub bucket: String, pub record: String, /// Who sent it, as the mail server saw it. pub from: String, #[serde(default)] pub subject: String, /// The reply itself, already stripped of quoted history and /// signatures by the reader; portal stores what it is given. pub text: String, pub received_ms: i64, } /// What a reply may add to a record, and what it may never do. Returns /// the note to append, or why the reply is not one. /// /// The address a reply comes back on is unguessable, so it is proof of /// having been sent the mail - but a mailbox is a public door, and an /// address can be forged. The sender must therefore also match the /// address the record already holds, which is the one portal wrote to /// in the first place. pub fn note_for(incoming: &Incoming, answer: &crate::answers::Answer) -> Result { if incoming.v != CONTRACT { return Err(format!("reply contract v{} is not v{CONTRACT}", incoming.v)); } let text = incoming.text.trim(); if text.is_empty() { return Err("the reply has no text".to_string()); } let held = answer .responses .get("email") .and_then(|v| v.as_str()) .map(|e| e.trim().to_lowercase()) .filter(|e| !e.is_empty()) .ok_or_else(|| "the record holds no address to compare the sender with".to_string())?; if address_of(&incoming.from) != held { return Err(format!("{} is not the address this record was written to", incoming.from)); } if answer.notes.iter().any(|n| n.id == incoming.id) { return Err("this reply is already on the record".to_string()); } Ok(crate::answers::Note { id: incoming.id.clone(), from: address_of(&incoming.from), subject: incoming.subject.trim().to_string(), text: text.to_string(), received_ms: incoming.received_ms, }) } /// The bare address out of `Name ` or `a@b`, folded for /// comparison. Mail addresses are case-insensitive in the part that /// matters here, and a display name is not identity. pub fn address_of(from: &str) -> String { let inner = match (from.rfind('<'), from.rfind('>')) { (Some(open), Some(close)) if close > open => &from[open + 1..close], _ => from, }; inner.trim().to_lowercase() } /// Listens for replies and writes each one onto the record it names. /// A reply is a note and never a decision: nothing here moves a case, /// so a mailbox - the one door anyone may knock on - cannot drive a /// state machine even if someone forges their way past the checks. pub async fn receive(state: crate::server::AppState) { use futures::StreamExt; let mut sub = match state.nats.subscribe(RECEIVED_SUBJECT).await { Ok(sub) => sub, Err(e) => { tracing::error!(error = %e, "cannot listen for replies"); return; } }; tracing::info!(subject = RECEIVED_SUBJECT, "listening for replies to case mail"); while let Some(msg) = sub.next().await { let incoming: Incoming = match serde_json::from_slice(&msg.payload) { Ok(i) => i, Err(e) => { tracing::warn!("unreadable reply on {RECEIVED_SUBJECT}: {e}"); continue; } }; match note_onto(&state, &incoming).await { Ok(()) => tracing::info!(bucket = %incoming.bucket, record = %incoming.record, "a reply was added to the record"), Err(e) => tracing::warn!(bucket = %incoming.bucket, record = %incoming.record, "reply refused: {e}"), } } } /// The record's own bucket is the only one a reply may touch, and the /// note is appended under the same read-modify-write the desks use. async fn note_onto(state: &crate::server::AppState, incoming: &Incoming) -> anyhow::Result<()> { if !state.aggregates.load().contains_key(&incoming.bucket) && !crate::content::bucket_is_read(&state.questions.load(), &incoming.bucket) { anyhow::bail!("{} is not a bucket of this site", incoming.bucket); } let store = state.jetstream.get_key_value(&incoming.bucket).await?; let bytes = store .get(&incoming.record) .await? .ok_or_else(|| anyhow::anyhow!("no such record"))?; let mut answer: crate::answers::Answer = serde_json::from_slice(&bytes)?; let note = note_for(incoming, &answer).map_err(|e| anyhow::anyhow!(e))?; answer.notes.push(note); store.put(&incoming.record, serde_json::to_vec(&answer)?.into()).await?; Ok(()) } /// Idempotent, called by portal at boot and by gdo. A week of /// retention: a mail nobody delivered in a week is stale, not owed. /// The duplicate window has to fit inside it. pub async fn ensure_stream(js: &async_nats::jetstream::Context) -> anyhow::Result { Ok(js .get_or_create_stream(async_nats::jetstream::stream::Config { name: STREAM.to_string(), subjects: vec![SUBJECTS.to_string()], max_age: Duration::from_secs(7 * 24 * 3600), duplicate_window: Duration::from_secs(24 * 3600), ..Default::default() }) .await?) } /// `{key}` in a template becomes the variable's value, an unknown key /// becomes nothing, and a brace that opens no key stays a brace. pub fn render(template: &str, vars: &serde_json::Map) -> String { let mut out = String::with_capacity(template.len()); let mut rest = template; while let Some(start) = rest.find('{') { out.push_str(&rest[..start]); let after = &rest[start + 1..]; let key_len = after .char_indices() .take_while(|(_, c)| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || *c == '.') .last() .map(|(i, c)| i + c.len_utf8()) .unwrap_or(0); if key_len > 0 && after[key_len..].starts_with('}') { let key = &after[..key_len]; match vars.get(key) { Some(serde_json::Value::String(s)) => out.push_str(s), Some(serde_json::Value::Null) | None => {} Some(other) => out.push_str(&other.to_string()), } rest = &after[key_len + 1..]; } else { out.push('{'); rest = after; } } out.push_str(rest); out } /// The HTML alternative: the markdown body as a single column with /// inline styles, which is as much as mail clients agree on. Raw HTML /// in the body is dropped, the same rule as page descriptions. pub fn to_html(markdown: &str) -> String { use pulldown_cmark::{html, Event, Options, Parser}; let parser = Parser::new_ext(markdown, Options::empty()).filter(|e| !matches!(e, Event::Html(_) | Event::InlineHtml(_))); let mut body = String::new(); html::push_html(&mut body, parser); format!( "
{body}
" ) } fn public_url() -> String { std::env::var("PUBLIC_URL").unwrap_or_default().trim_end_matches('/').to_string() } fn site_name(site: &crate::content::SiteConfig) -> String { site.title.clone().unwrap_or_else(|| option_env!("SITE_NAME").unwrap_or("portal").to_string()) } /// The variables a state mail may use: every field of the record, /// then the runtime's own. pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Value) -> serde_json::Map { let mut vars = serde_json::Map::new(); if let Some(fields) = responses.as_object() { for (k, v) in fields { vars.insert(k.clone(), match v { serde_json::Value::String(s) => serde_json::Value::String(s.clone()), serde_json::Value::Null => serde_json::Value::String(String::new()), other => serde_json::Value::String(other.to_string()), }); } } let email = responses.get("email").and_then(|v| v.as_str()).unwrap_or_default().trim().to_string(); vars.insert("email".into(), email.into()); vars.insert("site".into(), public_url().into()); vars.insert("chain".into(), id.into()); vars.insert("bucket".into(), bucket.into()); vars.insert("state".into(), state.into()); 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> { let inner = to.trim().strip_prefix('{')?.strip_suffix('}')?; let (field, _) = inner.split_once('.')?; (!field.is_empty() && !inner.contains(char::is_whitespace)).then_some(field) } /// The fields of every record this one belongs to, as `{field.name}`: /// for each `type: record` field of the forms that record into /// `bucket`, the record it names, read from its own bucket. A /// reference that does not resolve adds nothing, and a template that /// needed it renders empty there. async fn parent_vars(state: &crate::server::AppState, bucket: &str, responses: &serde_json::Value, vars: &mut serde_json::Map) { let questions = state.questions.load(); let refs: Vec<(String, String)> = questions .values() .flat_map(|q| q.alternatives.iter()) .filter(|a| a.record_as.as_deref() == Some(bucket)) .flat_map(|a| a.features.iter().flat_map(|f| f.requirements.iter())) .filter(|r| r.kind == "record") .filter_map(|r| Some((r.name.clone(), r.of.clone()?))) .collect(); for (field, of) in refs { let Some(id) = responses.get(&field).and_then(|v| v.as_str()).filter(|s| !s.is_empty()) else { continue }; let Ok(store) = state.jetstream.get_key_value(&of).await else { continue }; let Ok(Some(bytes)) = store.get(id).await else { continue }; let Ok(parent) = serde_json::from_slice::(&bytes) else { continue }; if let Some(fields) = parent.responses.as_object() { for (k, v) in fields { let text = match v { serde_json::Value::String(s) => s.clone(), serde_json::Value::Null => String::new(), other => other.to_string(), }; vars.insert(format!("{field}.{k}"), text.into()); } } vars.insert(format!("{field}.id"), parent.id.into()); vars.insert(format!("{field}.state"), parent.state.into()); } } async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow::Result<()> { let mut headers = async_nats::HeaderMap::new(); headers.insert("Nats-Msg-Id", mail.id.as_str()); let bytes = serde_json::to_vec(mail)?; js.publish_with_headers(SUBJECT, headers, bytes.into()).await?.await?; Ok(()) } /// A record entered `state` in `bucket`: send what the schema says, /// if anything. Best-effort by design - the transition is already /// committed, and a mail that cannot be rendered is logged, never a /// failed request. pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value, storyline: u32) { // The mail tells the storyline the person read. let storyline = storyline as usize; let Some(spec) = state.aggregates.load().at(storyline).get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else { // A suggestion has its own words for this unless the content // gave the state a mail, which then is the one sent. if bucket == crate::content::EDIT_SUGGESTIONS_BUCKET { on_suggestion_state(state, id, to_state, responses).await; } return; }; let site_storylines = state.site.load(); let site = site_storylines.at(storyline); let Some(cfg) = &site.mail else { tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent"); return; }; 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(), None => { tracing::warn!(bucket, id, to_state, "mail to the submitter, but the record carries no email - not sent"); return; } } } else { // A field of the record this one belongs to, or an address. let to = render(&spec.to, &vars); if !to.contains('@') { tracing::warn!(bucket, id, to_state, to = %spec.to, "the mail's address did not resolve to one - not sent"); return; } to }; let text = render(&spec.body, &vars); let mail = Outgoing { v: CONTRACT, id: format!("{bucket}:{id}:{to_state}"), site: site_name(&site), from: cfg.from.clone(), reply_to: cfg.reply_to.clone(), to, subject: render(&spec.subject, &vars), html: to_html(&text), text, }; if let Err(e) = publish(&state.jetstream, &mail).await { tracing::error!(bucket, id, to_state, "publishing the mail failed: {e}"); } } /// The questions a person answers for, as a mail names them: each in /// quotes, and past the first three only how many more - someone who /// answers for a whole site is not read its table of contents. pub fn questions_listed(questions: &[String], norwegian: bool) -> String { let quote = |q: &String| if norwegian { format!("«{q}»") } else { format!("\u{201c}{q}\u{201d}") }; let and = if norwegian { "og" } else { "and" }; match questions { [] => String::new(), [one] => quote(one), few if few.len() <= 3 => { let quoted: Vec = few.iter().map(quote).collect(); format!("{} {and} {}", quoted[..quoted.len() - 1].join(", "), quoted[quoted.len() - 1]) } many => { let quoted: Vec = many[..3].iter().map(quote).collect(); let more = many.len() - 3; let others = match (norwegian, more) { (true, 1) => "ett spørsmål til".to_string(), (true, n) => format!("{n} spørsmål til"), (false, 1) => "one more question".to_string(), (false, n) => format!("{n} more questions"), }; format!("{} {and} {others}", quoted.join(", ")) } } } /// The mail a person gets when the content names them responsible for /// a question and they had no account: who they are to the site (the /// person listed as asking that question), and the link that lets them /// sign in and suggest changes to it. Built in, in the site's language. pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::people::Invited, questions: &[String]) { let site = state.site.load(); let Some(cfg) = &site.mail else { tracing::warn!(user = %invited.username, "an account was made for a responsible person, but site.yaml has no `mail.from` - they were not told"); return; }; let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn")); let list = questions_listed(questions, norwegian); let (subject, body) = if norwegian { ( "Du står som ansvarlig for et spørsmål hos {site_name}".to_string(), "Hei {name},\n\nDu står oppført som ansvarlig for å stille {questions} hos {site_name}. Derfor har du fått en konto der, så du kan logge inn og foreslå endringer i det du står bak.\n\nVelg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}. Etterpå logger du inn på {site}.".to_string(), ) } else { ( "You are listed as responsible for a question at {site_name}".to_string(), "Hello {name},\n\nYou are listed as responsible for asking {questions} at {site_name}. So you now have an account there, to sign in and suggest changes to what you stand behind.\n\nSet your password or passkey here; the link works until {expires}:\n\n{link}\n\nYour username is {username}. Afterwards you sign in at {site}.".to_string(), ) }; let now_ms = chrono::Utc::now().timestamp_millis(); let expires = invited .link_expires_ms .and_then(chrono::DateTime::::from_timestamp_millis) .map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()) .unwrap_or_default(); 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("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("expires".into(), expires.into()); let text = render(&body, &vars); let mail = Outgoing { v: CONTRACT, id: format!("responsible:{}:{now_ms}", invited.username), site: site_name(&site), from: cfg.from.clone(), reply_to: cfg.reply_to.clone(), to: invited.email.clone(), subject: render(&subject, &vars), html: to_html(&text), text, }; if let Err(e) = publish(&state.jetstream, &mail).await { tracing::error!(user = %invited.username, "publishing the responsible mail failed: {e}"); } } /// What the person who suggested a change hears when it moves, as /// subject and body; `None` for a state that says nothing new to them /// (it arriving, or one the content added). `{name}`, `{question}`, /// `{page}`, `{quoted}`. pub fn suggestion_words(to_state: &str, norwegian: bool) -> Option<(&'static str, &'static str)> { Some(match (to_state, norwegian) { ("discussed", true) => ( "Forslaget ditt om «{question}» blir sett på", "Hei {name},\n\nForslaget ditt til endring på {page} er tatt opp, og blir sett på nå. Du får beskjed når det er avgjort.\n\nDu skrev:\n\n{quoted}", ), ("done", true) => ( "Endringen du foreslo i «{question}» er gjort", "Hei {name},\n\nEndringen du foreslo på {page} er gjort. Takk for at du sa fra.\n\nDu skrev:\n\n{quoted}", ), ("declined", true) => ( "Forslaget ditt om «{question}» blir ikke gjort", "Hei {name},\n\nForslaget ditt til endring på {page} blir ikke gjort slik det står. Svar på denne e-posten om du vil vite hvorfor, eller vil foreslå det på en annen måte.\n\nDu skrev:\n\n{quoted}", ), ("discussed", false) => ( "Your suggestion about \u{201c}{question}\u{201d} is being looked at", "Hello {name},\n\nYour suggested change to {page} has been taken up and is being looked at. You will hear when it is settled.\n\nYou wrote:\n\n{quoted}", ), ("done", false) => ( "The change you suggested to \u{201c}{question}\u{201d} is made", "Hello {name},\n\nThe change you suggested to {page} is made. Thank you for saying so.\n\nYou wrote:\n\n{quoted}", ), ("declined", false) => ( "Your suggestion about \u{201c}{question}\u{201d} will not be made", "Hello {name},\n\nYour suggested change to {page} will not be made as it stands. Answer this mail if you want to know why, or to suggest it another way.\n\nYou wrote:\n\n{quoted}", ), _ => return None, }) } /// A suggestion moved: tell whoever made it. Replies go to the site's /// inbox, which is who decided. async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state: &str, responses: &serde_json::Value) { let site = state.site.load(); let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn")); let Some((subject, body)) = suggestion_words(to_state, norwegian) else { return; }; let Some(cfg) = &site.mail else { tracing::warn!(id, to_state, "a suggestion moved, but site.yaml has no `mail.from` - whoever made it was not told"); return; }; let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).unwrap_or_default().trim().to_string(); let to = field("email"); if !to.contains('@') { tracing::warn!(id, to_state, "a suggestion moved, but the record carries no email - not sent"); return; } let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::>().join("\n"); 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()); vars.insert("quoted".into(), quoted.into()); let text = render(body, &vars); let mail = Outgoing { v: CONTRACT, id: format!("{}:{id}:{to_state}", crate::content::EDIT_SUGGESTIONS_BUCKET), site: site_name(&site), from: cfg.from.clone(), reply_to: cfg.reply_to.clone(), to, subject: render(subject, &vars), html: to_html(&text), text, }; if let Err(e) = publish(&state.jetstream, &mail).await { tracing::error!(id, to_state, "publishing the suggestion mail failed: {e}"); } } /// A change suggested by the person responsible for a page, to the /// site's own inbox (`mail.reply_to`, else `mail.from`), with the /// suggester as reply-to so answering the mail answers them. pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &str, question_name: &str, by_name: &str, by_email: &str, text: &str) { let site = state.site.load(); let Some(cfg) = &site.mail else { tracing::warn!(question_id, "an edit was suggested, but site.yaml has no `mail.from` - it is in portal_edit_suggestions only"); return; }; let to = cfg.reply_to.clone().unwrap_or_else(|| cfg.from.clone()); let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn")); let (subject, lead) = if norwegian { (format!("Forslag til endring: «{question_name}»"), format!("{by_name} ({by_email}), som står som ansvarlig for spørsmålet, foreslår en endring på {}{question_id}:", public_url())) } else { (format!("Suggested change: \u{201c}{question_name}\u{201d}"), format!("{by_name} ({by_email}), listed as responsible for the question, suggests a change to {}{question_id}:", public_url())) }; let body = format!("{lead}\n\n{text}"); let now_ms = chrono::Utc::now().timestamp_millis(); let mail = Outgoing { v: CONTRACT, id: format!("suggestion:{question_id}:{now_ms}"), site: site_name(&site), from: cfg.from.clone(), reply_to: Some(by_email.to_string()), to, subject, html: to_html(&body), text: body, }; if let Err(e) = publish(&state.jetstream, &mail).await { tracing::error!(question_id, "publishing the suggestion mail failed: {e}"); } } /// The invite mail: the one-time link to the person just onboarded. /// Built in, since portal knows everything about it; `mail.invite` in /// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`, /// `{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 { 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) { let site = state.site.load(); let Some(cfg) = &site.mail else { tracing::warn!(user = %invited.username, "invite made, but site.yaml has no `mail.from` - the link is on the desk only"); return; }; 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 (invited.mail.as_ref().or(cfg.invite.as_ref()), invited.reset_link.is_some(), norwegian) { (Some(t), _, _) => (t.subject.clone(), t.body.clone()), (None, true, true) => ( "Du er invitert inn i {group} på {site_name}".to_string(), "Hei {name},\n\nDu har fått en konto på {site} og en plass i {group}. Velg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}.".to_string(), ), (None, false, true) => ( "Du har fått plass i {group} på {site_name}".to_string(), "Hei {name},\n\nDu har fått plass i {group} på {site_name}. Logg inn på {site}, så er plassen din der.".to_string(), ), (None, true, false) => ( "You are invited into {group} at {site_name}".to_string(), "Hello {name},\n\nYou have an account at {site} and a place in {group}. Set your password or passkey here; the link works until {expires}:\n\n{link}\n\nYour username is {username}.".to_string(), ), (None, false, false) => ( "You now have a place in {group} at {site_name}".to_string(), "Hello {name},\n\nYou now have a place in {group} at {site_name}. Sign in at {site}, and it is there.".to_string(), ), }; let now_ms = chrono::Utc::now().timestamp_millis(); let expires = invited .link_expires_ms .and_then(chrono::DateTime::::from_timestamp_millis) .map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()) .unwrap_or_default(); let mut vars = serde_json::Map::new(); vars.insert("name".into(), invited.display_name.clone().into()); 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("expires".into(), expires.into()); let text = render(&body, &vars); let mail = Outgoing { v: CONTRACT, id: format!("invite:{}:{}:{now_ms}", invited.username, invited.group), site: site_name(&site), from: cfg.from.clone(), reply_to: cfg.reply_to.clone(), to: invited.email.clone(), subject: render(&subject, &vars), html: to_html(&text), text, }; if let Err(e) = publish(&state.jetstream, &mail).await { tracing::error!(user = %invited.username, "publishing the invite mail failed: {e}"); } } #[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] fn a_mail_names_three_questions_and_counts_the_rest() { let q = |n: usize| (1..=n).map(|i| format!("Q{i}?")).collect::>(); assert_eq!(questions_listed(&q(0), true), ""); assert_eq!(questions_listed(&q(1), true), "«Q1?»"); assert_eq!(questions_listed(&q(3), true), "«Q1?», «Q2?» og «Q3?»"); assert_eq!(questions_listed(&q(4), true), "«Q1?», «Q2?», «Q3?» og ett spørsmål til"); assert_eq!(questions_listed(&q(17), true), "«Q1?», «Q2?», «Q3?» og 14 spørsmål til"); assert_eq!(questions_listed(&q(2), false), "\u{201c}Q1?\u{201d} and \u{201c}Q2?\u{201d}"); assert_eq!(questions_listed(&q(5), false), "\u{201c}Q1?\u{201d}, \u{201c}Q2?\u{201d}, \u{201c}Q3?\u{201d} and 2 more questions"); } #[test] fn a_suggestion_says_something_each_time_it_moves_and_nothing_on_arrival() { for norwegian in [true, false] { for state in ["discussed", "done", "declined"] { let (subject, body) = suggestion_words(state, norwegian).expect(state); assert!(subject.contains("{question}"), "{state}: the subject names the question"); assert!(body.contains("{page}") && body.contains("{quoted}"), "{state}: the body says where and what"); } // Arriving is told to the inbox, not back to who sent it. assert!(suggestion_words("open", norwegian).is_none()); assert!(suggestion_words("a_state_the_content_added", norwegian).is_none()); } } #[test] fn a_mail_reaches_through_a_record_field_and_nothing_else() { assert_eq!(parent_of("{notice.email}"), Some("notice")); assert_eq!(parent_of(" {case.contact} "), Some("case")); assert_eq!(parent_of("submitter"), None); assert_eq!(parent_of("post@vel.no"), None); assert_eq!(parent_of("{email}"), None); assert_eq!(parent_of("{.email}"), None); assert_eq!(parent_of("{not a.field}"), None); } #[test] fn templates_take_fields_and_leave_stray_braces_alone() { let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null }); let vars = vars_for("reports", "abc", "fixed", &responses); assert_eq!(render("Hi {name}, {count} at {site}/decide?chain={chain}&email={email}", &vars), "Hi Kari, 3 at /decide?chain=abc&email=kari@x.no"); assert_eq!(render("{unknown} {hva} {not a key} {", &vars), " {not a key} {"); assert_eq!(vars["bucket"], "reports"); 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] fn the_html_alternative_keeps_markdown_and_drops_raw_html() { let html = to_html("Hello **there**\n\n\n\n[link](https://x.no)"); assert!(html.contains("there")); assert!(html.contains("link")); assert!(!html.contains("