A reply to a case mail becomes a note on its record
Whatever reads the mailbox publishes on portal.mail.received; portal appends the note after checking the record exists in a bucket this site has and the sender is the address the record itself holds. A note never moves a case: a mailbox is the one door anyone may knock on, so it cannot drive a state machine even if someone forges their way past the checks. The desks show the conversation beside the case. Tested: the address parser, every refusal (a stranger, no address to compare, an empty reply, the same reply twice, an unknown contract), and the whole path through a real JetStream - the note lands, the state does not move, and no event is written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4fd60293fa
commit
26c1762dfa
@@ -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<version>` 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.
|
||||
|
||||
Generated
+1
-1
@@ -3265,7 +3265,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.3.47"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.3.47"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -28,6 +28,26 @@ pub struct Answer {
|
||||
pub decided_ms: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub decided_by: Option<String>,
|
||||
/// 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<Note>,
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
||||
+26
-1
@@ -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<Option<map::MapWidget>, 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(
|
||||
<span class="answer-state">{state}</span>
|
||||
<span class="answer-time">{format_ms(submitted_ms)}</span>
|
||||
</div>
|
||||
// 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! {
|
||||
<ol class="answer-notes">
|
||||
<For
|
||||
each=move || notes.clone()
|
||||
key=|note| note.id.clone()
|
||||
children=move |note| {
|
||||
view! {
|
||||
<li class="answer-note">
|
||||
<span class="answer-note-who">{note.from}</span>
|
||||
<span class="answer-note-time">{format_ms(note.received_ms)}</span>
|
||||
<p class="answer-note-text">{note.text}</p>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</ol>
|
||||
}
|
||||
})}
|
||||
<Show when={
|
||||
let available = available.clone();
|
||||
move || !available.is_empty()
|
||||
|
||||
+205
@@ -19,6 +19,10 @@ use std::time::Duration;
|
||||
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;
|
||||
@@ -41,6 +45,123 @@ pub struct Outgoing {
|
||||
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<crate::answers::Note, String> {
|
||||
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 <a@b>` 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 <KARI@x.no>", " 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>"), "kari@x.no");
|
||||
assert_eq!(address_of(" kari@x.no "), "kari@x.no");
|
||||
assert_eq!(address_of("<kari@x.no>"), "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>"), "kari@x.no");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 <A@B.example>".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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user