A reply to a case mail becomes a note on its record
Test / test (push) Failing after 53s
Publish release / publish (push) Successful in 3m14s

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:
Bendik Aagaard Lynghaug
2026-09-24 21:26:57 +02:00
co-authored by Claude Opus 5
parent 4fd60293fa
commit 26c1762dfa
9 changed files with 337 additions and 3 deletions
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
}
+5
View File
@@ -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