diff --git a/CHANGELOG.md b/CHANGELOG.md index a769950..a1eadfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## 0.4.0 (2026-09-24) + +- A field can be a place: `type: location` puts a pin on a map and stores `{lat, lon, zoom}`; a record holding one renders as a still map on its desk and in any resource card. +- A question can be about a place: `place: { lat, lon, zoom, name }` is the counterpart to `event:` - event makes a page an occasion with a time, place makes it somewhere, draws the map under the question, and opens any location field on that page there instead of on the globe. +- Maps are served from this origin. Portal fetches every tile and every still itself (`src/maps.rs`, MapLibre vendored), so a page with a map on it makes no third-party request, needs no consent banner, and the `MAPBOX_TOKEN` never leaves the server. Unset, the feature is simply off. +- A reply to a case mail becomes a note on its record: whatever reads the mailbox publishes on `portal.mail.received` and portal appends the note, after checking the record exists and the sender is the address the record itself holds. A note never moves a case. + ## 0.3.47 (2026-09-24) - iris: needs.yaml takes `stage_words`, each state in the site's own words, carried into the simulation model for the trainer's desk score. No change to portal itself. diff --git a/Cargo.lock b/Cargo.lock index 14bd984..3da782e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3265,7 +3265,7 @@ dependencies = [ [[package]] name = "portal" -version = "0.3.47" +version = "0.4.0" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 3ca3a71..be09eb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "portal" -version = "0.3.47" +version = "0.4.0" edition = "2021" [lib] diff --git a/src/answers.rs b/src/answers.rs index 384f39b..020da49 100644 --- a/src/answers.rs +++ b/src/answers.rs @@ -28,6 +28,26 @@ pub struct Answer { pub decided_ms: Option, #[serde(default)] pub decided_by: Option, + /// Replies that came back by mail, oldest first (see + /// `mail::note_for`). A note never moves a record; it is the + /// conversation beside the case, which is the thing a desk could + /// not see before. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, +} + +/// One reply on a record. It lives here rather than with the mail +/// code because it is part of what a record is: the desks render it, +/// and the browser has to know its shape even though it will never +/// speak to a mail server. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Note { + pub id: String, + pub from: String, + #[serde(default)] + pub subject: String, + pub text: String, + pub received_ms: i64, } /// Writes a submitted answer into `bucket`, creating it on first use - @@ -70,6 +90,7 @@ pub async fn store_answer( state: initial_state, submitted_ms, decided_ms: None, + notes: Vec::new(), decided_by: None, }; let data = serde_json::to_vec(&answer)?; @@ -419,6 +440,7 @@ mod tests { submitted_ms: 1_700_000_000_000, decided_ms: None, decided_by: None, + notes: Vec::new(), }, Answer { id: "def456".to_string(), @@ -429,6 +451,7 @@ mod tests { submitted_ms: 1_700_000_001_000, decided_ms: Some(1_700_000_002_000), decided_by: Some("bl".to_string()), + notes: Vec::new(), }, ]; let value = serde_json::Value::Array( diff --git a/src/app.rs b/src/app.rs index 1ce235d..0c68a42 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1158,6 +1158,7 @@ fn AlternativeCard( let question_id = question_id.clone(); let alt_name = alt_name.clone(); let feature_name = feature_name.clone(); + let _place = place.clone(); move |req| { let field_id = format!("{}-{}", field_prefix, req.name); let label_for = field_id.clone(); @@ -1329,7 +1330,7 @@ fn AlternativeCard( .value .clone() .filter(|v| !v.is_empty()) - .unwrap_or_else(|| place.clone()); + .unwrap_or_else(|| _place.clone()); let widget: StoredValue, LocalStorage> = StoredValue::new_local(None); Effect::new(move |_| { @@ -1975,6 +1976,7 @@ fn AnswerRow( .cloned() .unwrap_or_default(); let submitted_ms = answer.submitted_ms; + let notes = answer.notes.clone(); let state = answer.state.clone(); let item_id = answer.id.clone(); let key = (feature_name.clone(), item_id.clone()); @@ -2025,6 +2027,29 @@ fn AnswerRow( {state} {format_ms(submitted_ms)} + // What the person has written back since, oldest first: the + // conversation beside the case, which a desk could not see + // before. A note moves nothing, so there is no button here. + {(!notes.is_empty()) + .then(|| { + view! { +
    + + {note.from} + {format_ms(note.received_ms)} +

    {note.text}

    + + } + } + /> +
+ } + })} 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. @@ -280,5 +401,89 @@ mod tests { }; let back: Outgoing = serde_json::from_slice(&serde_json::to_vec(&mail).unwrap()).unwrap(); assert_eq!(back, mail); + // A gdo that predates reply addresses sends none, and reads + // one from a portal that does: the field is optional on the + // wire in both directions. + let older: Outgoing = serde_json::from_str( + r#"{"v":1,"id":"a","site":"s","from":"f@x.no","to":"t@x.no","subject":"s","text":"t","html":"h"}"#, + ) + .unwrap(); + assert_eq!(older.subject, "s"); + assert!(serde_json::to_string(&older).unwrap().contains("\"html\":\"h\"")); + } + + fn record(email: &str) -> crate::answers::Answer { + crate::answers::Answer { + id: "abc".into(), + question_id: "/report".into(), + alternative: "Report a hazard".into(), + responses: serde_json::json!({ "email": email, "where": "The bend" }), + state: "open".into(), + submitted_ms: 1, + decided_ms: None, + decided_by: None, + notes: Vec::new(), + } + } + + fn reply(from: &str, text: &str) -> Incoming { + Incoming { + v: CONTRACT, + id: "mail-1".into(), + bucket: "hazards".into(), + record: "abc".into(), + from: from.into(), + subject: "Re: We have it".into(), + text: text.into(), + received_ms: 2, + } + } + + #[test] + fn a_reply_from_the_address_on_the_record_becomes_a_note() { + let note = note_for(&reply("Kari ", " It is fixed now. "), &record("kari@x.no")).unwrap(); + assert_eq!(note.from, "kari@x.no"); + assert_eq!(note.text, "It is fixed now."); + assert_eq!(note.id, "mail-1"); + assert_eq!(note.received_ms, 2); + } + + #[test] + fn a_reply_from_anyone_else_is_refused() { + // The address a reply arrives on is unguessable, but a From is + // a claim, and a mailbox is a door anyone may knock on. + let err = note_for(&reply("someone@else.no", "Let me in"), &record("kari@x.no")).unwrap_err(); + assert!(err.contains("not the address this record was written to"), "{err}"); + + // Nothing to compare against is also a refusal, not a pass. + let mut anonymous = record("kari@x.no"); + anonymous.responses = serde_json::json!({ "where": "The bend" }); + assert!(note_for(&reply("kari@x.no", "hi"), &anonymous).unwrap_err().contains("no address")); + } + + #[test] + fn an_empty_reply_and_a_repeated_one_add_nothing() { + assert!(note_for(&reply("kari@x.no", " \n "), &record("kari@x.no")).unwrap_err().contains("no text")); + + let mut already = record("kari@x.no"); + already.notes.push(note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap()); + let err = note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap_err(); + assert!(err.contains("already on the record"), "{err}"); + } + + #[test] + fn a_reply_from_a_contract_this_portal_does_not_know_is_refused() { + let mut future = reply("kari@x.no", "hello"); + future.v = CONTRACT + 1; + assert!(note_for(&future, &record("kari@x.no")).unwrap_err().contains("contract")); + } + + #[test] + fn an_address_is_read_out_of_whatever_the_mail_server_said() { + assert_eq!(address_of("Kari Nordmann "), "kari@x.no"); + assert_eq!(address_of(" kari@x.no "), "kari@x.no"); + assert_eq!(address_of(""), "kari@x.no"); + // A display name that looks like an address is not the address. + assert_eq!(address_of("\"admin@bank.no\" "), "kari@x.no"); } } diff --git a/src/main.rs b/src/main.rs index 1d7d804..3421b0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -108,6 +108,11 @@ async fn main() -> anyhow::Result<()> { // windows (see announce.rs). tokio::spawn(portal::announce::run(state.clone())); + // Replies to case mail, matched to their record by whatever reads + // the mailbox (uhhm/gdo over JMAP) and turned into notes here. A + // site with no mailbox simply never sees a message on the subject. + tokio::spawn(portal::mail::receive(state.clone())); + // Dev-friendly defaults: in-memory sessions, secure cookies only when // COOKIE_SECURE=true (set it behind TLS in production) - same // defaults cnats uses. SameSite=Lax (tower-sessions defaults to diff --git a/style/main.css b/style/main.css index 9c45a48..799fda3 100644 --- a/style/main.css +++ b/style/main.css @@ -1187,3 +1187,43 @@ textarea:focus { .answer-field-map .answer-field-value { display: block; } + +/* Replies that came back by mail, under the record they belong to. + They move nothing, so there is no control here - only who wrote, + when, and what they said. */ +.answer-notes { + list-style: none; + margin: 0.4rem 0 0; + padding: 0 0 0 0.7rem; + border-left: 0.12rem solid var(--line); + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.answer-note-who { + font-weight: 600; +} + +.answer-note-time { + margin-left: 0.4rem; + font-size: 0.85em; + color: var(--ink-dim); +} + +.answer-note-text { + margin: 0.15rem 0 0; + white-space: pre-wrap; +} + +/* A page that is about a place: the map sits under the question, at + the width of the text it belongs to. */ +.question-place { + margin: 0.8rem 0 0; +} + +.question-place figcaption { + margin-top: 0.3rem; + font-size: 0.9em; + color: var(--ink-dim); +} diff --git a/tests/jetstream.rs b/tests/jetstream.rs index 9dc373d..d14efd0 100644 --- a/tests/jetstream.rs +++ b/tests/jetstream.rs @@ -63,4 +63,33 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en let replayed = aggregates::replay(schema, "rec-1", &events).expect("replays"); assert_eq!(replayed.state, "won"); assert!(replayed.sequence > moved.sequence); + + // A reply to the mail this record was sent becomes a note on it, + // through the same store the desks read - and moves nothing: the + // record is still where the desk left it. + let mut record: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap(); + let reply = portal::mail::Incoming { + v: portal::mail::CONTRACT, + id: "mail-1".into(), + bucket: bucket.clone(), + record: "rec-1".into(), + from: "A ".into(), + subject: "Re: your enquiry".into(), + text: "Thursday suits us.".into(), + received_ms: now + 10, + }; + let note = portal::mail::note_for(&reply, &record).expect("the address matches the record"); + record.notes.push(note); + store.put("rec-1", serde_json::to_vec(&record).unwrap().into()).await.expect("note stored"); + + let read_back: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap(); + assert_eq!(read_back.notes.len(), 1); + assert_eq!(read_back.notes[0].text, "Thursday suits us."); + assert_eq!(read_back.notes[0].from, "a@b.example"); + assert_eq!(read_back.state, "quoted", "a reply is a note, never a decision"); + // The same reply read twice adds nothing. + assert!(portal::mail::note_for(&reply, &read_back).is_err()); + // And the event log is untouched by it. + let after = load_events(&js, &bucket, "rec-1").await.expect("events"); + assert_eq!(after.len(), 3, "a note writes no event"); }