Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c68396d5b | ||
|
|
1ca3ac6807 | ||
|
|
19c8f541de | ||
|
|
6d60f8b9a3 | ||
|
|
f36d88a69c | ||
|
|
ef46b463e4 | ||
|
|
4818c5a26f |
@@ -147,6 +147,7 @@ jobs:
|
|||||||
auth="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
|
auth="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
|
||||||
# The release notes are this version's section of CHANGELOG.md.
|
# The release notes are this version's section of CHANGELOG.md.
|
||||||
notes=$(awk -v v="${tag#v}" '/^## /{p = ($2 == v)} p' CHANGELOG.md | tail -n +2)
|
notes=$(awk -v v="${tag#v}" '/^## /{p = ($2 == v)} p' CHANGELOG.md | tail -n +2)
|
||||||
|
[ -n "$(printf "%s" "$notes" | tr -d "[:space:]")" ] || { echo "CHANGELOG.md has no section for $tag - write it and re-tag"; exit 1; }
|
||||||
body=$(jq -n --arg tag "$tag" --arg notes "$notes" '{tag_name: $tag, name: $tag, body: $notes}')
|
body=$(jq -n --arg tag "$tag" --arg notes "$notes" '{tag_name: $tag, name: $tag, body: $notes}')
|
||||||
id=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
|
id=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||||
-d "$body" "$api/releases" | jq .id) \
|
-d "$body" "$api/releases" | jq .id) \
|
||||||
|
|||||||
+29
-3
@@ -4,6 +4,32 @@ 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.3.44 (2026-09-23)
|
||||||
|
|
||||||
|
- The mail stream is named `mail` (was `WORMHOLE`); gdo 0.1.2 matches. Delete the old stream on each NATS after upgrading both.
|
||||||
|
|
||||||
|
## 0.3.43 (2026-09-23)
|
||||||
|
|
||||||
|
- Mail is content: `mail: { to, subject, body }` on a state in aggregates.yaml sends when a record enters that state (`to: submitter` or one address; `{field}`, `{site}`, `{chain}`, `{email}` in the templates), and an invite mails its one-time link built in; `site.yaml` gains `mail: { from, reply_to, invite }`. Portal renders and publishes on `portal.mail.send` in the `WORMHOLE` stream; uhhm/gdo delivers. The lint requires a required `email` field on every form recording into a bucket that mails the submitter.
|
||||||
|
|
||||||
|
## 0.3.42 (2026-09-23)
|
||||||
|
|
||||||
|
- people: the Kanidm person lookup reads the body, since Kanidm 1.11 answers a missing person with 200 and `null` rather than 404; before this every invite skipped account creation and failed on the group step
|
||||||
|
|
||||||
|
## 0.3.41 (2026-09-23)
|
||||||
|
|
||||||
|
- publish: the iris release step retries on a fresh clone when its push loses a race against a push to iris main (the v0.3.40 tag never published)
|
||||||
|
- iris: `check --access` prints the access matrix, and every check validates the compiled policy against the schema
|
||||||
|
|
||||||
|
## 0.3.40 (2026-09-23)
|
||||||
|
|
||||||
|
- One access policy, compiled from content into Cedar on every load (`src/access.rs`): pages, resource reads, desk transitions, self transitions and the automation endpoint all decide through it; decisions carry rule ids, refusals and mutations publish on `portal.access.decided`, and `portal.access.<site>.may` answers the same question for other apps over NATS
|
||||||
|
- `invite: {group}` on an alternative onboards a person into Kanidm from a desk (`src/people.rs`, `KANIDM_API_TOKEN`), recording the one-time credential link in `portal_invites` and on `portal.people.invited`
|
||||||
|
|
||||||
|
## 0.3.39 (2026-09-23)
|
||||||
|
|
||||||
|
- publish: releases the matching uhhm/iris by proxy - re-pins, builds, tests and tags it as this version (needs the `IRIS_PUSH_USER` variable and `IRIS_PUSH_TOKEN` secret); iris carries portal's version number from here on
|
||||||
|
|
||||||
## 0.3.38 (2026-09-22)
|
## 0.3.38 (2026-09-22)
|
||||||
|
|
||||||
- publish: the iris build step gets a writable cargo home; the v0.3.37 tag never published
|
- publish: the iris build step gets a writable cargo home; the v0.3.37 tag never published
|
||||||
@@ -287,6 +313,6 @@ tarball deploy on kasse.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| uhhm.no | ergo | `app@uhhm-portal` | v0.3.36 |
|
| uhhm.no | ergo | `app@uhhm-portal` | v0.3.36 |
|
||||||
| redoal.com | ergo | `app@redoal-portal` | v0.3.36 |
|
| redoal.com | ergo | `app@redoal-portal` | v0.3.36 |
|
||||||
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.3.36 |
|
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.3.41 |
|
||||||
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.3.36 |
|
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.3.41 |
|
||||||
| vel.klingenbergbygg.no | kasse | `app@tomtervel-portal` | v0.3.36 |
|
| vel.klingenbergbygg.no | kasse | `app@tomtervel-portal` | v0.3.41 |
|
||||||
|
|||||||
Generated
+1
-1
@@ -3265,7 +3265,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.3.41"
|
version = "0.3.44"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.3.41"
|
version = "0.3.44"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -36,6 +36,32 @@ pub struct AggregateSchema {
|
|||||||
/// rule has an explicit, auditable opt-out instead of a silent
|
/// rule has an explicit, auditable opt-out instead of a silent
|
||||||
/// one, and so the next reader knows where the answers go.
|
/// one, and so the next reader knows where the answers go.
|
||||||
pub attended_by: Option<String>,
|
pub attended_by: Option<String>,
|
||||||
|
/// The mail a record's arrival in a state sends (see `mail.rs`):
|
||||||
|
/// declared on the state in aggregates.yaml, next to the event
|
||||||
|
/// that enters it, so content decides when a person hears
|
||||||
|
/// something and the mailer never has to know a site.
|
||||||
|
pub mail_for_state: HashMap<String, MailSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mail to send when a record enters a state. `to` is `submitter`
|
||||||
|
/// (the record's `email` field, which the lint then requires the
|
||||||
|
/// forms to collect) or one fixed address. Subject and body are
|
||||||
|
/// templates: `{field}` for any field of the record, plus `{site}`
|
||||||
|
/// (the instance's public URL), `{chain}` (the record id), `{email}`,
|
||||||
|
/// `{bucket}` and `{state}`. The body is markdown; the mailer sends it
|
||||||
|
/// as plain text with an HTML alternative.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MailSpec {
|
||||||
|
pub to: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MailSpec {
|
||||||
|
pub fn to_submitter(&self) -> bool {
|
||||||
|
self.to == "submitter"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AggregateSchema {
|
impl AggregateSchema {
|
||||||
@@ -43,6 +69,10 @@ impl AggregateSchema {
|
|||||||
self.event_for_state.contains_key(state)
|
self.event_for_state.contains_key(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn mail_for(&self, state: &str) -> Option<&MailSpec> {
|
||||||
|
self.mail_for_state.get(state)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn allowed(&self, state: &str) -> &[String] {
|
pub fn allowed(&self, state: &str) -> &[String] {
|
||||||
self.transitions
|
self.transitions
|
||||||
.get(state)
|
.get(state)
|
||||||
@@ -54,6 +84,8 @@ impl AggregateSchema {
|
|||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
struct RawState {
|
struct RawState {
|
||||||
event: String,
|
event: String,
|
||||||
|
#[serde(default)]
|
||||||
|
mail: Option<MailSpec>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
@@ -123,6 +155,22 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut mail_for_state = HashMap::new();
|
||||||
|
for (state, raw_state) in raw_schema.states {
|
||||||
|
if let Some(mail) = raw_state.mail {
|
||||||
|
if mail.subject.trim().is_empty() || mail.body.trim().is_empty() {
|
||||||
|
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: mail needs a subject and a body");
|
||||||
|
}
|
||||||
|
if !mail.to_submitter() && !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace)) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"aggregates.yaml: bucket {bucket:?}: state {state:?}: mail.to must be `submitter` or one address, not {:?}",
|
||||||
|
mail.to
|
||||||
|
);
|
||||||
|
}
|
||||||
|
mail_for_state.insert(state, mail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
out.insert(
|
out.insert(
|
||||||
bucket.clone(),
|
bucket.clone(),
|
||||||
AggregateSchema {
|
AggregateSchema {
|
||||||
@@ -132,6 +180,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
|||||||
state_for_event,
|
state_for_event,
|
||||||
transitions: raw_schema.transitions,
|
transitions: raw_schema.transitions,
|
||||||
attended_by: raw_schema.attended_by,
|
attended_by: raw_schema.attended_by,
|
||||||
|
mail_for_state,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -349,6 +398,37 @@ aggregates:
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_state_may_carry_the_mail_its_arrival_sends() {
|
||||||
|
let schemas = parse_aggregates_yaml(
|
||||||
|
r#"
|
||||||
|
aggregates:
|
||||||
|
- bucket: reports
|
||||||
|
initial: open
|
||||||
|
states:
|
||||||
|
open: { event: received, mail: { to: submitter, subject: "Got it, {name}", body: "We read it. {site}/decide?chain={chain}" } }
|
||||||
|
fixed: { event: fixed, mail: { to: board@example.no, subject: "Fixed: {where}", body: "Done." } }
|
||||||
|
dropped: { event: dropped }
|
||||||
|
transitions:
|
||||||
|
open: [fixed, dropped]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let s = &schemas["reports"];
|
||||||
|
assert!(s.mail_for("open").unwrap().to_submitter());
|
||||||
|
assert_eq!(s.mail_for("fixed").unwrap().to, "board@example.no");
|
||||||
|
assert!(s.mail_for("dropped").is_none());
|
||||||
|
|
||||||
|
for bad in [
|
||||||
|
"mail: { to: submitter, subject: \"\", body: b }",
|
||||||
|
"mail: { to: \"the board\", subject: s, body: b }",
|
||||||
|
"mail: { to: submitter, subject: s, body: b, cc: x }",
|
||||||
|
] {
|
||||||
|
let yaml = format!("aggregates:\n - bucket: r\n initial: open\n states:\n open: {{ event: received, {bad} }}\n");
|
||||||
|
assert!(parse_aggregates_yaml(&yaml).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn event(seq: u64, event_type: &str) -> Event {
|
fn event(seq: u64, event_type: &str) -> Event {
|
||||||
Event {
|
Event {
|
||||||
id: uuid::Uuid::new_v4(),
|
id: uuid::Uuid::new_v4(),
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ async fn apply_transition(
|
|||||||
.put(&item.item_id, data.into())
|
.put(&item.item_id, data.into())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
crate::mail::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
|
||||||
|
|
||||||
// The decision extends the DAG as a child of the answer's own
|
// The decision extends the DAG as a child of the answer's own
|
||||||
// submission hash, published like any other answer.
|
// submission hash, published like any other answer.
|
||||||
@@ -378,6 +379,7 @@ pub async fn self_transition_answer(
|
|||||||
.put(&item_id, data.into())
|
.put(&item_id, data.into())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
crate::mail::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await;
|
||||||
|
|
||||||
let parent_hashes = vec![item_id.clone()];
|
let parent_hashes = vec![item_id.clone()];
|
||||||
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
||||||
|
|||||||
@@ -2245,6 +2245,7 @@ pub async fn submit_answer(
|
|||||||
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
||||||
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.into()).await;
|
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.into()).await;
|
||||||
}
|
}
|
||||||
|
crate::mail::on_invite(&state, &invited).await;
|
||||||
}
|
}
|
||||||
// `/shape/{curve.key}`: the next page may be chosen by the answer.
|
// `/shape/{curve.key}`: the next page may be chosen by the answer.
|
||||||
let next = match alt.action.as_deref() {
|
let next = match alt.action.as_deref() {
|
||||||
@@ -2298,6 +2299,11 @@ pub async fn submit_answer(
|
|||||||
{
|
{
|
||||||
tracing::error!("failed to store answer in {bucket}: {e}");
|
tracing::error!("failed to store answer in {bucket}: {e}");
|
||||||
}
|
}
|
||||||
|
// Arrival is a state too: what the schema says the first
|
||||||
|
// state sends, it sends now.
|
||||||
|
if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) {
|
||||||
|
crate::mail::on_state(&state, bucket, &chain_hash, &initial, &responses).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(SubmitResult { next, chain_hash })
|
Ok(SubmitResult { next, chain_hash })
|
||||||
|
|||||||
@@ -543,6 +543,32 @@ pub struct SiteConfig {
|
|||||||
pub favicon: Option<String>,
|
pub favicon: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hero: HeroConfig,
|
pub hero: HeroConfig,
|
||||||
|
/// Who this site's mail comes from. Absent means the site sends
|
||||||
|
/// nothing: a state's `mail:` and the invite mail are logged and
|
||||||
|
/// skipped, never half-sent (see `mail.rs`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub mail: Option<MailConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sender identity, and the one built-in mail's wording.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct MailConfig {
|
||||||
|
/// `Name <address>` or a bare address, on a domain the host's
|
||||||
|
/// mail server may send for.
|
||||||
|
pub from: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reply_to: Option<String>,
|
||||||
|
/// Rewords the invite mail; the built-in text is in the site's
|
||||||
|
/// `lang`. `{name}`, `{username}`, `{email}`, `{group}`, `{link}`,
|
||||||
|
/// `{site}`, `{site_name}`, `{expires}`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub invite: Option<MailTemplate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct MailTemplate {
|
||||||
|
pub subject: String,
|
||||||
|
pub body: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the landing page's hero is. `plain` is just the copy; `module`
|
/// What the landing page's hero is. `plain` is just the copy; `module`
|
||||||
|
|||||||
@@ -92,6 +92,29 @@ pub fn validate_questions(
|
|||||||
questions: &std::collections::HashMap<String, Question>,
|
questions: &std::collections::HashMap<String, Question>,
|
||||||
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
|
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
|
// A mail to the submitter needs an address to go to: every form
|
||||||
|
// that records into that bucket has to collect a required email.
|
||||||
|
for (bucket, schema) in aggregates {
|
||||||
|
let submitter_states: Vec<&String> = schema.mail_for_state.iter().filter(|(_, m)| m.to_submitter()).map(|(s, _)| s).collect();
|
||||||
|
if submitter_states.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for question in questions.values() {
|
||||||
|
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
||||||
|
let collects_email = alternative
|
||||||
|
.features
|
||||||
|
.iter()
|
||||||
|
.flat_map(|f| &f.requirements)
|
||||||
|
.any(|r| r.name == "email" && r.kind == "email" && !r.optional);
|
||||||
|
if !collects_email {
|
||||||
|
anyhow::bail!(
|
||||||
|
"question {:?} alternative {:?}: records into {bucket:?}, whose state(s) {:?} mail the submitter, so it must collect a required `email` field of type email",
|
||||||
|
question.id, alternative.name, submitter_states
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for question in questions.values() {
|
for question in questions.values() {
|
||||||
if let Some(event) = &question.event {
|
if let Some(event) = &question.event {
|
||||||
if question.is_dynamic() {
|
if question.is_dynamic() {
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ pub mod access;
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod people;
|
pub mod people;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod mail;
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|
||||||
|
|||||||
+284
@@ -0,0 +1,284 @@
|
|||||||
|
//! 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";
|
||||||
|
/// 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,
|
||||||
|
/// `<bucket>:<record>:<state>` or `invite:<user>:<group>:<ms>` -
|
||||||
|
/// the dedup key.
|
||||||
|
pub id: String,
|
||||||
|
pub site: String,
|
||||||
|
pub from: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reply_to: Option<String>,
|
||||||
|
pub to: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub text: String,
|
||||||
|
pub html: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<async_nats::jetstream::stream::Stream> {
|
||||||
|
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, serde_json::Value>) -> 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!(
|
||||||
|
"<!doctype html><html><body style=\"margin:0;padding:1.5em;background:#f4f4f2;color:#1c1c1a;font:16px/1.5 -apple-system,Segoe UI,Helvetica,Arial,sans-serif\"><div style=\"max-width:36em;margin:0 auto;background:#fff;padding:1.5em 2em;border-radius:0.5em\">{body}</div></body></html>"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, serde_json::Value> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
let Some(spec) = state.aggregates.load().get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let site = state.site.load();
|
||||||
|
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 vars = vars_for(bucket, id, to_state, responses);
|
||||||
|
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 {
|
||||||
|
spec.to.clone()
|
||||||
|
};
|
||||||
|
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 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}`.
|
||||||
|
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 (&cfg.invite, 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\nKontoen din på {site} har nå plass i {group}. Logg inn som vanlig.".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\nYour account at {site} now has a place in {group}. Sign in as usual.".to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
|
let expires = invited
|
||||||
|
.link_expires_ms
|
||||||
|
.and_then(chrono::DateTime::<chrono::Utc>::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.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 {
|
||||||
|
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 {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[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 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)");
|
||||||
|
assert!(html.contains("<strong>there</strong>"));
|
||||||
|
assert!(html.contains("<a href=\"https://x.no\">link</a>"));
|
||||||
|
assert!(!html.contains("<script>"));
|
||||||
|
assert!(html.starts_with("<!doctype html>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_contract_round_trips() {
|
||||||
|
let mail = Outgoing {
|
||||||
|
v: CONTRACT,
|
||||||
|
id: "reports:abc:fixed".into(),
|
||||||
|
site: "Tomter Vel".into(),
|
||||||
|
from: "Tomter Vel <vel@example.no>".into(),
|
||||||
|
reply_to: None,
|
||||||
|
to: "kari@x.no".into(),
|
||||||
|
subject: "Fixed".into(),
|
||||||
|
text: "Done.".into(),
|
||||||
|
html: to_html("Done."),
|
||||||
|
};
|
||||||
|
let back: Outgoing = serde_json::from_slice(&serde_json::to_vec(&mail).unwrap()).unwrap();
|
||||||
|
assert_eq!(back, mail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// into, and `answers::store_answer` creates it on first use.
|
// into, and `answers::store_answer` creates it on first use.
|
||||||
let jetstream = async_nats::jetstream::new(nats.clone());
|
let jetstream = async_nats::jetstream::new(nats.clone());
|
||||||
portal::events::store::ensure_stream(&jetstream).await?;
|
portal::events::store::ensure_stream(&jetstream).await?;
|
||||||
|
// Outgoing mail waits here for gdo; a mail is never lost to gdo
|
||||||
|
// being down, and never sent by portal itself.
|
||||||
|
portal::mail::ensure_stream(&jetstream).await?;
|
||||||
|
|
||||||
let oidc_state = Arc::new(oidc::Oidc::from_env().await?);
|
let oidc_state = Arc::new(oidc::Oidc::from_env().await?);
|
||||||
|
|
||||||
|
|||||||
+24
-6
@@ -31,6 +31,19 @@ pub struct Invited {
|
|||||||
pub created: bool,
|
pub created: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a `GET /v1/person/<name>` answer means the person exists.
|
||||||
|
/// Kanidm 1.11 answers a missing person with 200 and the body `null`
|
||||||
|
/// rather than 404, so the status alone would report everyone as
|
||||||
|
/// existing and no account would ever get created. `None` for an
|
||||||
|
/// answer that is neither.
|
||||||
|
fn person_lookup_found(status: u16, body: &str) -> Option<bool> {
|
||||||
|
match status {
|
||||||
|
200 => Some(body.trim() != "null" && !body.trim().is_empty()),
|
||||||
|
404 => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Kanidm {
|
pub struct Kanidm {
|
||||||
base: String,
|
base: String,
|
||||||
token: String,
|
token: String,
|
||||||
@@ -60,12 +73,8 @@ impl Kanidm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn person_exists(&self, username: &str) -> anyhow::Result<bool> {
|
async fn person_exists(&self, username: &str) -> anyhow::Result<bool> {
|
||||||
let (status, _) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}"), None).await?;
|
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}"), None).await?;
|
||||||
match status {
|
person_lookup_found(status, &text).ok_or_else(|| anyhow::anyhow!("kanidm: looking up {username}: http {status}"))
|
||||||
200 => Ok(true),
|
|
||||||
404 => Ok(false),
|
|
||||||
other => anyhow::bail!("kanidm: looking up {username}: http {other}"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The whole onboarding, in the order that leaves the least behind
|
/// The whole onboarding, in the order that leaves the least behind
|
||||||
@@ -161,4 +170,13 @@ mod tests {
|
|||||||
assert_eq!(username_for("42@x.no"), "person");
|
assert_eq!(username_for("42@x.no"), "person");
|
||||||
assert_eq!(username_for("Ærlig@x.no"), "rlig");
|
assert_eq!(username_for("Ærlig@x.no"), "rlig");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_null_person_answer_means_nobody_there() {
|
||||||
|
assert_eq!(person_lookup_found(200, "null"), Some(false));
|
||||||
|
assert_eq!(person_lookup_found(200, "null\n"), Some(false));
|
||||||
|
assert_eq!(person_lookup_found(200, r#"{"attrs":{"name":["bl"]}}"#), Some(true));
|
||||||
|
assert_eq!(person_lookup_found(404, ""), Some(false));
|
||||||
|
assert_eq!(person_lookup_found(401, "unauthorized"), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user